From 26dccbcc3d14062ba65989c7831e456b2a138311 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:34:55 +0200 Subject: [PATCH 01/14] Show for coders my-coding-jobs --- api-dto/workspaces/workspace-user-dto.ts | 2 +- apps/frontend/src/app/coding/coding.routes.ts | 5 + .../my-coding-jobs.component.html | 91 +++++++ .../my-coding-jobs.component.scss | 233 ++++++++++++++++++ .../my-coding-jobs.component.ts | 180 ++++++++++++++ .../src/app/components/home/home.component.ts | 29 ++- 6 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.html create mode 100644 apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.scss create mode 100644 apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts diff --git a/api-dto/workspaces/workspace-user-dto.ts b/api-dto/workspaces/workspace-user-dto.ts index a307f4c4b..9b7bf8119 100644 --- a/api-dto/workspaces/workspace-user-dto.ts +++ b/api-dto/workspaces/workspace-user-dto.ts @@ -8,5 +8,5 @@ export class WorkspaceUserDto { userId!: number; @ApiProperty({ nullable: true }) - accessLevel!: string | null; + accessLevel!: number | null; } diff --git a/apps/frontend/src/app/coding/coding.routes.ts b/apps/frontend/src/app/coding/coding.routes.ts index bd4f51578..1ffcf2e95 100644 --- a/apps/frontend/src/app/coding/coding.routes.ts +++ b/apps/frontend/src/app/coding/coding.routes.ts @@ -11,5 +11,10 @@ export const codingRoutes: Routes = [ path: 'test-person-coding/:workspace_id', canActivate: [canActivateAuth], loadComponent: () => import('./components/test-person-coding/test-person-coding.component').then(m => m.TestPersonCodingComponent) + }, + { + path: 'coding', + canActivate: [canActivateAuth], + loadComponent: () => import('./components/my-coding-jobs/my-coding-jobs.component').then(m => m.MyCodingJobsComponent) } ]; diff --git a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.html b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.html new file mode 100644 index 000000000..39eb9e49c --- /dev/null +++ b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.html @@ -0,0 +1,91 @@ +
+
+

Meine Kodierjobs

+

Hier finden Sie alle Kodierjobs, die Ihnen zugewiesen wurden

+ + @if (isLoading) { +
+ +

Kodierjobs werden geladen...

+
+ } + + @if (!isAuthorized && !isLoading) { +
+ lock +

Zugriff verweigert

+

Sie haben keinen Zugriff auf diese Seite. Nur Kodierer (Benutzer mit Zugriffsebene 1) können auf diese Seite zugreifen.

+ Zurück zur Startseite +
+ } + + @if (!isLoading && isAuthorized) { +
+ + +
+ + + + + + + Name + + {{element.name}} + + + + + Beschreibung + + {{element.description}} + + + + + Status + + {{getStatusText(element.status)}} + + + + + Erstellt am + + {{element.createdAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + Aktualisiert am + + {{element.updatedAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + @if (dataSource.data.length === 0) { +
+ assignment +

Keine Kodierjobs für Sie vorhanden.

+
+ } +
+ +
+ } +
+
diff --git a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.scss b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.scss new file mode 100644 index 000000000..7baa88816 --- /dev/null +++ b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.scss @@ -0,0 +1,233 @@ +.page-body { + height: calc(90% - 5px); +} + +.admin-background { + box-shadow: 5px 10px 20px black; + background-color: white; + padding: 25px; + margin: 0 15px 15px 15px; + height: calc(100% - 65px); +} + +.page-title { + font-size: 28px; + font-weight: 500; + margin: 0 0 8px 0; + color: #333; +} + +.subtitle { + font-size: 16px; + color: #666; + margin: 0 0 20px 0; +} + +.flex-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.filter-section { + margin-bottom: 16px; +} + +.filter-action-container { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.table-container { + flex: 1; + overflow: auto; + position: relative; +} + +.coding-jobs-table { + width: 100%; + min-width: 800px; /* Ensures table doesn't get too narrow */ +} + +.cell-content { + padding: 8px 0; + display: block; +} + +.date-cell { + color: #666; + font-size: 14px; +} + +.action-section { + display: flex; + justify-content: flex-end; + padding: 16px 0 0; + margin-top: 10px; +} + +.action-button { + padding: 0 24px; + height: 48px; + font-size: 16px; +} + +.no-data-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 32px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: #9e9e9e; + } + + p { + font-size: 18px; + color: #666; + margin: 0; + } +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 24px; + margin-top: 20px; + + p { + font-size: 18px; + color: #666; + } +} + +.unauthorized-message { + margin: 40px auto; + padding: 20px; + text-align: center; + max-width: 600px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: #f44336; + } + + h3 { + font-size: 24px; + margin: 0; + color: #333; + } + + p { + font-size: 16px; + color: #666; + margin: 0 0 16px 0; + max-width: 450px; + line-height: 1.5; + } + + a { + margin-top: 8px; + } +} + +/* Status badges */ +.status-badge { + padding: 6px 12px; + border-radius: 16px; + font-size: 14px; + font-weight: 500; + display: inline-block; +} + +.status-active { + background-color: rgba(76, 175, 80, 0.1); + color: #2e7d32; + border: 1px solid rgba(76, 175, 80, 0.2); +} + +.status-completed { + background-color: rgba(33, 150, 243, 0.1); + color: #1565c0; + border: 1px solid rgba(33, 150, 243, 0.2); +} + +.status-pending { + background-color: rgba(255, 152, 0, 0.1); + color: #ef6c00; + border: 1px solid rgba(255, 152, 0, 0.2); +} + +/* Table styling */ +mat-header-row { + background-color: #f8f9fa; + border-bottom: 2px solid #e0e0e0; + min-height: 56px; +} + +mat-row { + min-height: 52px; + transition: background-color 0.2s ease; +} + +mat-row:hover { + background-color: rgba(0, 0, 0, 0.02); + cursor: pointer; +} + +mat-row.selected { + background-color: rgba(33, 150, 243, 0.05); +} + +mat-header-cell { + color: #424242; + font-size: 14px; + font-weight: 500; +} + +mat-cell { + font-size: 15px; + color: #333; +} + +/* Responsive adjustments */ +@media (max-width: 1200px) { + .content-wrapper { + padding: 16px 24px; + } +} + +@media (max-width: 768px) { + .header-section { + padding: 16px 24px; + } + + .content-wrapper { + padding: 16px; + } + + .page-title { + font-size: 24px; + } +} diff --git a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts new file mode 100644 index 000000000..6e2513897 --- /dev/null +++ b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts @@ -0,0 +1,180 @@ +import { + Component, OnInit, ViewChild, AfterViewInit, inject +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { + MatCell, MatCellDef, MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, MatHeaderRowDef, + MatRow, MatRowDef, + MatTable, + MatTableDataSource +} from '@angular/material/table'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SelectionModel } from '@angular/cdk/collections'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatAnchor, MatButton } from '@angular/material/button'; +import { DatePipe, NgClass } from '@angular/common'; +import { Router } from '@angular/router'; +import { AppService } from '../../../services/app.service'; +import { BackendService } from '../../../services/backend.service'; +import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; +import { CodingJob } from '../../models/coding-job.model'; +import { WorkspaceUserDto } from '../../../../../../../api-dto/workspaces/workspace-user-dto'; + +@Component({ + selector: 'coding-box-my-coding-jobs', + templateUrl: './my-coding-jobs.component.html', + styleUrls: ['./my-coding-jobs.component.scss'], + standalone: true, + imports: [ + TranslateModule, + DatePipe, + NgClass, + SearchFilterComponent, + MatIcon, + MatHeaderCell, + MatCell, + MatHeaderRow, + MatRow, + MatProgressSpinner, + MatTable, + MatAnchor, + MatHeaderCellDef, + MatCellDef, + MatHeaderRowDef, + MatRowDef, + MatColumnDef, + MatSortModule, + MatButton + ] +}) +export class MyCodingJobsComponent implements OnInit, AfterViewInit { + appService = inject(AppService); + backendService = inject(BackendService); + private snackBar = inject(MatSnackBar); + private router = inject(Router); + + displayedColumns: string[] = ['name', 'description', 'status', 'createdAt', 'updatedAt']; + dataSource = new MatTableDataSource([]); + selection = new SelectionModel(true, []); + isLoading = false; + currentUserId = 0; + isAuthorized = false; + + @ViewChild(MatSort) sort!: MatSort; + + ngOnInit(): void { + this.appService.authData$.subscribe(authData => { + this.currentUserId = authData.userId; + if (authData.workspaces && authData.workspaces.length > 0) { + this.checkUserAccessLevel(); + } else { + this.router.navigate(['/']); + this.snackBar.open('Sie haben keinen Zugriff auf diese Seite', 'Schließen', { duration: 3000 }); + } + }); + } + + private checkUserAccessLevel(): void { + this.backendService.getWorkspaceUsers(1).subscribe(users => { + const currentUser = users.data.find((user: WorkspaceUserDto) => user.userId === this.currentUserId); + if (currentUser && currentUser.accessLevel === 1) { + this.isAuthorized = true; + this.loadMyCodingJobs(); + } else { + this.router.navigate(['/']); + this.snackBar.open( + 'Sie haben keinen Zugriff auf diese Seite. Nur Kodierer können auf diese Seite zugreifen.', + 'Schließen', + { duration: 3000 } + ); + } + }); + } + + ngAfterViewInit(): void { + this.dataSource.sort = this.sort; + } + + loadMyCodingJobs(): void { + this.isLoading = true; + + setTimeout(() => { + const allJobs = [ + { + id: 1, + name: 'Kodierjob 1', + description: 'Beschreibung für Kodierjob 1', + status: 'active', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-15'), + assignedCoders: [1, 2] + }, + { + id: 2, + name: 'Kodierjob 2', + description: 'Beschreibung für Kodierjob 2', + status: 'completed', + createdAt: new Date('2023-02-01'), + updatedAt: new Date('2023-02-15'), + assignedCoders: [3] + }, + { + id: 3, + name: 'Kodierjob 3', + description: 'Beschreibung für Kodierjob 3', + status: 'pending', + createdAt: new Date('2023-03-01'), + updatedAt: new Date('2023-03-15'), + assignedCoders: [1] + } + ]; + + this.dataSource.data = allJobs.filter(job => job.assignedCoders.includes(this.currentUserId)); + + this.isLoading = false; + }, 500); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + selectRow(row: CodingJob): void { + this.selection.toggle(row); + } + + startCodingJob(job: CodingJob): void { + this.snackBar.open(`Starten von Kodierjob "${job.name}" noch nicht implementiert`, 'Schließen', { duration: 3000 }); + } + + getStatusClass(status: string): string { + switch (status) { + case 'active': + return 'status-active'; + case 'completed': + return 'status-completed'; + case 'pending': + return 'status-pending'; + default: + return ''; + } + } + + getStatusText(status: string): string { + switch (status) { + case 'active': + return 'Aktiv'; + case 'completed': + return 'Abgeschlossen'; + case 'pending': + return 'Ausstehend'; + default: + return status; + } + } +} diff --git a/apps/frontend/src/app/components/home/home.component.ts b/apps/frontend/src/app/components/home/home.component.ts index 2e5618d5e..fd9aedcc5 100755 --- a/apps/frontend/src/app/components/home/home.component.ts +++ b/apps/frontend/src/app/components/home/home.component.ts @@ -7,12 +7,13 @@ import { MatButtonModule } from '@angular/material/button'; import { ReactiveFormsModule } from '@angular/forms'; import { TranslateModule } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppService } from '../../services/app.service'; import { AppInfoComponent } from '../app-info/app-info.component'; import { UserWorkspacesAreaComponent } from '../../workspace/components/user-workspaces-area/user-workspaces-area.component'; import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace-full-dto'; +import { BackendService } from '../../services/backend.service'; @Component({ selector: 'coding-box-home', @@ -32,10 +33,13 @@ import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace export class HomeComponent implements OnInit, OnDestroy { readonly appService = inject(AppService); private route = inject(ActivatedRoute); + private router = inject(Router); private snackBar = inject(MatSnackBar); + private backendService = inject(BackendService); workspaces: WorkspaceFullDto[] = []; authData = AppService.defaultAuthData; + private isCoderChecked = false; private authSubscription?: Subscription; @@ -45,6 +49,11 @@ export class HomeComponent implements OnInit, OnDestroy { if (authData) { this.authData = authData; this.workspaces = authData.workspaces; + + // Check if user is a coder and redirect if needed + if (!this.isCoderChecked && authData.userId > 0) { + this.checkIfUserIsCoder(authData.userId); + } } }); @@ -55,6 +64,24 @@ export class HomeComponent implements OnInit, OnDestroy { }); } + private checkIfUserIsCoder(userId: number): void { + this.isCoderChecked = true; + + if (!this.workspaces || this.workspaces.length === 0) { + return; + } + + const firstWorkspaceId = this.workspaces[0].id; + + this.backendService.getWorkspaceUsers(firstWorkspaceId).subscribe(response => { + const currentUser = response.data.find(user => user.userId === userId); + + if (currentUser && currentUser.accessLevel === 1) { + this.router.navigate(['/coding']); + } + }); + } + /** * Shows an error message based on the error code * @param errorCode The error code from the query parameters From 86a45a76816e057276149bb920dac44dd433e2dd Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:53:28 +0200 Subject: [PATCH 02/14] Add the new iqb logo --- apps/frontend/src/assets/images/IQB-LogoA.png | Bin 3834 -> 2386 bytes 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 apps/frontend/src/assets/images/IQB-LogoA.png diff --git a/apps/frontend/src/assets/images/IQB-LogoA.png b/apps/frontend/src/assets/images/IQB-LogoA.png old mode 100755 new mode 100644 index 8a1c70ed8e86905d2adfaf962745ace3a525bd53..f0cef34ced399163046378a4cbabb5a89681bce6 GIT binary patch literal 2386 zcmV-Y39a^tP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;(K){{a7>y{D4^000SaNLh0L02UzL_t(|+U=Wtkd;*($3LsEK#-KkJJ>=hj#k3x{4mCn z2%^zLR9aSG2&e=`LkJSEY9Pu>@+K@GmJv-#A`0{iOx_e*Y7R_fB}Od*L2b%gLW+n$ zf7~C_MT!(DQlvWx!0!~f zBBdgE4Os--5^JsmT1PcNk%2}|6z>9K9sc2Sosc>Sk#QmL<-nr_F)~^KQv-6vz&i=d z(AB56G1RWr`H#=}-Crx5nDG0lTs+W`E!zphe(u7oaaNLy_6Qy$*jkAlDF> zQ_cAuS7Z(FfWyC(m=95;ts)mknwA1T1a?H));oNKA~!2CM3G{LUkO|Ztcb|H0}M1G z9#UknA|EPn!dj%reA_TBa){Rh?gq*gX_|n~?g6~0$n8lw^+CW+MJg1T16%{l1lj}L zfhT~uiqrun0cAjiB6k9n4u7NVzWu;>hu^BmEx-?en}H3MQtfKIlnCvJ;9&z|1 zRg7`K;pa!b&r_rl7!{NIND>;-H4rWz05-{`I03A!vB^?o ziP_h!_IVj-XAZolIq{`dCi#ORJFSGWz~M_3nGW0o+y%^X_`QmZG}G}NMTR>3lZspq z%(KvSB``*jW;W?7fwhWsbNIjJK}d%{+xi@#_BspeqsUcRXrjp7_IC?gqmD6|#~glV zjR>)bqIV#7Xyot}K!rI#6uHk#Sqbm}FkX=rKre?MrN~s^s3MmF<09(+gd*Pp20Q$1 zimV5g08Jxg0JKbilu+dM&m4U!0&N%O=+hj?R&{vSHr_q8341#P$k^rZZv_gA{R5%h zdnb3F!@ptv;4z@VDb~SWz~LXa2qa1dF;Z%4zC!uUe{%FWoN%C5Y<{?RZ3qdy_|_~o zwIo-ju1H_t`heUaU_|8mG2mzc@{dq?=7Jo3>K9-hp8|eqkz9>6B#u`DZA}Al`|H38 zMOs>PgE`OffyEx_@VgZeMY=ltKC`=j3^?F?OxE^5Tk9NsE~$l9Z8JY}g=zV9R>OdMPkvHr7J>Qa}&k=`z)jnGszTDwQ1ZrM&%zRbP zsXP3BEBuwmOwTjGm+XTgyMa=N|CvQxEv%6CNirq*A}itzS7eeAeJwD|lD~Su(~;de zOOZ~%5F_R1!0-&GO7n-(W>f0wMKe;MZhMG&^UBFsD>wn0COX$Y(k5^?=;s_~BV? z2$^MNfU<~_T&+l5Mb;`Z%t|6^6B`NaGr1QW{sq&X?TS32NF#@zZdw0UB29jD@w!t%UL%nNzi)z>8M1+88M%>;MKi z{PT*mv&!#?s9CGQTo!WbiD6HNe?A~vY$5n?-hY`d8%8<&l)&#a0ru8lng?w&PY98l zCM!k~v1+Y+-)Z`vXM@ko~BW8W}ynvuD!F||` z3{qs8B268>+|0sU^E)RU_z=!AmvTnCp!F3Ig6plx6RKCuww^4FoeWjWCWT2uoJ~0ptr-XQ>1=X4(?n;Q31(l zWFc*n*rWYGdxw8BUH-!PkPrq0$N+daCF9HxGRJ&L^bBIJW%?=3gby*(RhP{1ZYi0< z+|+d0&yGl?*lvoaNRc8%iWDhQq)3q>MT!(DQhY%0KiI&&X|dzC&Hw-a07*qoM6N<$ Eg4KROI{*Lx literal 3834 zcmb7H`8yQe_qT+x4B00cSu$hG_8v=!EUAfM%%Ch;vlk=7lzm@AOi5#}VeD(ln`|?L z6q<}RF(FH`MRtAW{U3b4Kb(7>d!Bn<=XIWY&vVa7L0Vh~a*1&Wr5m!jOrnA(Q*qm5s6I#N4n8Vq%h_{wK_~69V5E#&g(fx3TB|Pb|*$ zfd>=D)yE&J?C*hE#D46PqJ{VL#lEN%nt+w;CgjQaEA)4e)c z=y=4&r{-D~;n#%X924N*6kD-N*O=4OnJc?Rt^q!F&PX7c)j;TEF)JYg$QsFS$bW`A zHOl^ctQ_a1R2K)U7^AxDCnrC?(E}rzetY6VLj3) z{o39rJlKvR?nKQUR#Y@o^h(b3_NZ`DU-^OZNer->hGG-EsI38;8%OM6JsQhVb9?O) zB`SxIhyjh24$jS4SDoWdziqEOXkF>V!9K^0#>uzEw)kJ*)+TQIZU8JL*?_y&zqA$9!uFG!Q`QUk3WP1 z`&VRa@J3hG>l*U}I7XKuAN^}MC~Yq7>i#}X=arKGw;0r}$MD=+ouG4~q%YRzIWqDW zUwqs{Xey*VXWwUgpcL|2dtoO_(`V`GDq{Ja(7CE|IYPd{J)=V5IYC5hv`#gGfAn8 zoDN{}(?A1*4G^Q#RUXS-o@Wa{=~x*%QR7%gHc%pkh$ ziBj$7?fXtjHtzW#-!$KqpZUS9nawrqWGlR!W@$F)72BZo2A%!Z4RtI{tLbKo>Hd~X z>F9PG68kYCaHdFk|61SC!p9nZFYjg5tp+U34AL7)a&P2I-fVdAcuF$2qV%^d^L7(W zD!f+Pv3lg9wv?%eS%R&3Xu`kyySFcqQ#2cM=_cYvMO%O)8Ef|r4uj!Y08oEx3p;8C z0RlRChL8;lm0&Jl{`BVi4{oJN5hcEb$Y6ZbO$lhBtvQ!XfmRoOLCx-+T-WQiV&3); z^h)k{vg)r2FY}wr8!9o^a{%BfostV{3uhP4Xb%~n-c)GctL^{*^~8I#dsAf;@zRBc zMRBVi*`&?GR?p!H_X>Pdf~_8nM9+V_l!;gN7N3*Wu6E;e*6E3Z8Y#+#=Rm+o>pjD1 z$eJoq^VHz7;=c&(aWotlwBP<@L)4=|hdD&$GICEaDbr>GJVqX+R3lai_bzN&@mB`# z#=@_}&9+onqbe`B`f%^|)D3>ea(U;jQKZmWY_i>#PmDyW&P_fiUQN)lE^^6QWyWXF zIGyE<-YbAWwPXamb5OoeR|r}hA8R^u;L>`Mt(}GKB|_RD1CQV~SbQrorcYmwU)>fX zWBF;@>Ms|2l$a<>>b)K=Q68fL6|1GlF-Y&c;rt2;@XCzp*_F`h9FNZSYEbkD)Zqi~ zUJ6U^H?7WR>k}{BnY!zgEfyXy^AeiM$aERPRx3ca9_6^SbaYWoomrQ1P53S}pWg32;WEknV|^D-mf4w&8ORW%yDgsExh?nUPrthdGdNh`4=StR_JPP1&h09_ zxe*`y_fxiqopD?ax+s0}S*fX8B?#Y5QY@F3v|}|(S_J5^H2Bp}vYMlZLZhH!(!^l> z&a<8@YAQT1Mp8e0uGMi_@DH0~Bd|ei^QeD+BiFwONF(m)+6II6w4*bd$B~3e26yH4 zd3cVnam8zi0HTtu?N!RJ2mPs2%YwNq=7%jx8INtv3EaZd#CuhMQeqeJ*5LQMDPFb# zwlgHjaG|{L=~0lCH{Nt)Mn}F49goVsK7m^ZY-Ft)e;*TLCvApEIO#-|9@Hph@Gxqx z)fc5&04Tj!S9EGE26cFo5_+e)wrbRqGHal^_&e)AoJE}#p!C!1IY>xN=8o{3j&6{s zLi!We3q<3II%43(|BfK=-)~y---M4~LXjd+GtBc2U{FpjBi7<__707Z1JRg#ZXzdZ zc(U$lmv7#!c05VR=|`dI!HDf%DkQzQO;#RpJDcjv$b=K*ljIAZ%C!OCXdJc6gT>a4 z!H*`#8Qupy79N6$Z&deR$t)U{ekK&_-QES3>DAN=<8?|)D40Mn%&2o!FtXjRF`%on z^9pkr^Le^{%^tvcWpDa=X@~FY5s2)u2!kdj!{BM}fIxV-v#WBUOyj6^`Xic5MBFJt z0&sdk3Z*&2FX}cBwhN zTSCAt<=Hr2Is|W^m@a5IZtlC8RKVuTDuiO-0$?T`ie&i#*)@n=zD1m~oc1l}yE3*h zaD${Wd~fQ@Fj@KO#5UVB2J}NbVQ1jtQ8YVUX{Q!ecuG@E%oLtX76z?H!XKAm2Z>#* zLq@wEiyRfpkX~NT;p&b%@sDlXqdVC2o6r(fK`zkKX4zBwtn@N6P^ods6Z$Os>E_Sz z*G&|s&xlkl_VTJX%7M1;GF9F`>sggVK4=P-=0>pL%K2&s4!4GHxL>0J@ouCJugMJ& zx;>iVG6$)Lhp5H8A6vl~3*LAAB7(UO_8X|z4oBbdRxV_fJrVKA2Q_N>w3Kftgl|Lx z#tEU_#2Zgsa9a6T9xYg4L7hkPA-jm7$Nm{u9?XKrjvzG6f=rDpyC3&5w3mqKj!fbn zH(9>jJ--L@4)j72+)i*?Zh;eJT+qvJ)i}%05H{m=%Bjb@_bqohID`ZR)vO}v@ z&CYI$8O^lTB*fO_!bDeP@rj`0gRX`1n|BT|$rA{W6aOU3)?Ymk;#;v z4;mBDhEk>U(qzUb|L;F5iX*>*@kMDHP=Ta0p&=OVQY+WvP?DZ4YS~P}RWxn0VM>*x zqvwQs){DB%sN!RSP{}Lg$}HPb2;b0XOp%vy`%jn>9iu7G!NYU9&ZG5}Ro+n{iyXGR zBKP#;IK7c4kbn^GP^Y@!O1c-eMn6N+C1)WK8uWOfxDDXXS}A(DMe44V=4$pTgeGMK zQxCsvN)Ct5%NjF+t=y#!v1C4MI`p6|3g!9jH~w?zb4=}@mt`J+ zcC^1tX`1i~?2PhxC&CC(48}XzRea(be`75Hx z?k0`+1wQZD9HG)p*x7Myi;L`NrIC`yE((7p%83tE;aAleEhu?jZR_ix5Gbzi7q1** zV;j&WAkhYOg(psjku=L@6csAr(0I??!z$AECoj#G^;2G`vzoVYK>e;>IpXRpD*FC3 z(>H29-U;~`p(CZ#p1(aJ?dZVgc>o!;dmH?3iAG`{&b(Dt$tR`V=v}<@%yVfW!*)07 z0Qo(ue_e7D?kkr8hn6NQ{~e z3LCzb7x#qHz6?AS%GH`lkqx|RQ%w8zgnTp4x)xKlk=ia~{#z|`>iZpQz=*)55+^e^*pWFO2P98}1dQ<>n`EKF*RTw?zRxPdNh From b3d3db62c504faf1d2db3fc2f3e970f08a2680a8 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 3 Aug 2025 18:22:47 +0200 Subject: [PATCH 03/14] Use a single query in findUnitResponse --- .../workspace-test-results.service.ts | 71 +++++++++++-------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 53f02ca21..ea1acfe05 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Connection, In, Repository } from 'typeorm'; +import { Connection, Repository } from 'typeorm'; import Persons from '../entities/persons.entity'; import { Unit } from '../entities/unit.entity'; import { Booklet } from '../entities/booklet.entity'; @@ -389,43 +389,54 @@ export class WorkspaceTestResultsService { // If not in cache, fetch from database const [login, code, bookletId] = connector.split('@'); - const person = await this.personsRepository.findOne({ - where: { - code, login, workspace_id: workspaceId, consider: true + const queryBuilder = this.unitRepository.createQueryBuilder('unit') + .innerJoinAndSelect('unit.responses', 'response') + .innerJoin('unit.booklet', 'booklet') + .innerJoin('booklet.person', 'person') + .innerJoin('booklet.bookletinfo', 'bookletinfo') + .where('person.login = :login', { login }) + .andWhere('person.code = :code', { code }) + .andWhere('person.workspace_id = :workspaceId', { workspaceId }) + .andWhere('person.consider = :consider', { consider: true }) + .andWhere('bookletinfo.name = :bookletId', { bookletId }) + .andWhere('unit.alias = :unitId', { unitId }); + + const unit = await queryBuilder.getOne(); + + if (!unit) { + // If no unit found, we need to determine which part of the query failed + const person = await this.personsRepository.findOne({ + where: { + code, login, workspace_id: workspaceId, consider: true + } + }); + + if (!person) { + throw new Error(`Person mit Login ${login} und Code ${code} wurde nicht gefunden.`); } - }); - if (!person) { - throw new Error(`Person mit ID ${person.id} wurde nicht gefunden.`); - } - const bookletInfo = await this.bookletInfoRepository.findOne({ - where: { name: bookletId } - }); + const bookletInfo = await this.bookletInfoRepository.findOne({ + where: { name: bookletId } + }); - if (!bookletInfo) { - throw new Error(`Kein Booklet mit der ID ${bookletId} gefunden.`); - } + if (!bookletInfo) { + throw new Error(`Kein Booklet mit der ID ${bookletId} gefunden.`); + } + + const booklet = await this.bookletRepository.findOne({ + where: { + personid: person.id, + infoid: bookletInfo.id + } + }); - const booklet = await this.bookletRepository.findOne({ - where: { - personid: person.id, - infoid: bookletInfo.id + if (!booklet) { + throw new Error(`Kein Booklet für die Person mit ID ${person.id} und Booklet ID ${bookletId} gefunden.`); } - }); - if (!booklet) { - throw new Error(`Kein Booklet für die Person mit ID ${person.id} und Booklet ID ${bookletId} gefunden.`); + throw new Error(`Keine Unit mit der ID ${unitId} für das Booklet ${bookletId} gefunden.`); } - const booklets = [booklet]; - const unit = await this.unitRepository.findOne({ - where: { - bookletid: In(booklets.map(b => b.id)), - alias: unitId - }, - relations: ['responses'] - }); - const responsesBySubform = {}; unit.responses.forEach(response => { From 789d977da1577e44bbadec87c2a0f9d1b40f04dd Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Sun, 3 Aug 2025 18:32:17 +0200 Subject: [PATCH 04/14] Apply cache only on findUnitResponse --- .../cache/booklet-cache-scheduler.service.ts | 99 --------- apps/backend/src/app/cache/cache.module.ts | 3 +- apps/backend/src/app/cache/cache.service.ts | 3 +- .../cache/response-cache-scheduler.service.ts | 193 ++++++++++++++---- .../workspace-test-results.service.ts | 152 +------------- 5 files changed, 158 insertions(+), 292 deletions(-) delete mode 100644 apps/backend/src/app/cache/booklet-cache-scheduler.service.ts diff --git a/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts b/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts deleted file mode 100644 index 8a2a80c27..000000000 --- a/apps/backend/src/app/cache/booklet-cache-scheduler.service.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CacheService } from './cache.service'; -import Persons from '../database/entities/persons.entity'; -import { WorkspaceTestResultsService } from '../database/services/workspace-test-results.service'; - -@Injectable() -export class BookletCacheSchedulerService { - private readonly logger = new Logger(BookletCacheSchedulerService.name); - private readonly BOOKLET_CACHE_TTL = 24 * 60 * 60; // 24 hours in seconds - - constructor( - private readonly cacheService: CacheService, - private readonly workspaceTestResultsService: WorkspaceTestResultsService, - @InjectRepository(Persons) - private readonly personsRepository: Repository - ) {} - - /** - * Scheduled task to cache all test person booklets - * Runs every night at 3:00 AM (after the response cache scheduler) - */ - @Cron(CronExpression.EVERY_DAY_AT_3AM) - async cacheAllBooklets() { - this.logger.log('Starting nightly task to cache all test person booklets'); - - try { - // Get all workspaces with persons - const workspaces = await this.getWorkspacesWithPersons(); - - for (const workspace of workspaces) { - const workspaceId = workspace.workspace_id; - this.logger.log(`Caching booklets for workspace ${workspaceId}`); - - // Get all test persons in this workspace - const persons = await this.personsRepository.find({ - where: { workspace_id: workspaceId, consider: true } - }); - - for (const person of persons) { - try { - // Cache the booklet data for this person - await this.cachePersonBooklets(person.id, workspaceId); - } catch (error) { - this.logger.error(`Error caching booklets for person ID ${person.id} in workspace ${workspaceId}: ${error.message}`, error.stack); - } - } - } - - this.logger.log('Finished nightly caching of all test person booklets'); - } catch (error) { - this.logger.error(`Error in cacheAllBooklets: ${error.message}`, error.stack); - } - } - - /** - * Get all workspaces that have persons - */ - private async getWorkspacesWithPersons(): Promise<{ workspace_id: number }[]> { - return this.personsRepository - .createQueryBuilder('person') - .select('DISTINCT person.workspace_id', 'workspace_id') - .where('person.consider = :consider', { consider: true }) - .getRawMany(); - } - - /** - * Cache booklet data for a specific person - */ - private async cachePersonBooklets(personId: number, workspaceId: number): Promise { - const cacheKey = this.generateBookletCacheKey(workspaceId, personId); - - // Check if already in cache - const exists = await this.cacheService.exists(cacheKey); - if (exists) { - this.logger.debug(`Booklet data already in cache for person ID ${personId} in workspace ${workspaceId}`); - return; - } - - // Fetch and cache the booklet data - try { - const bookletData = await this.workspaceTestResultsService.findPersonTestResults(personId, workspaceId); - await this.cacheService.set(cacheKey, bookletData, this.BOOKLET_CACHE_TTL); - this.logger.debug(`Cached booklet data for person ID ${personId} in workspace ${workspaceId}`); - } catch (error) { - this.logger.error(`Error fetching booklet data for caching: ${error.message}`, error.stack); - throw error; - } - } - - /** - * Generate a cache key for booklet data - */ - private generateBookletCacheKey(workspaceId: number, personId: number): string { - return `booklets:${workspaceId}:${personId}`; - } -} diff --git a/apps/backend/src/app/cache/cache.module.ts b/apps/backend/src/app/cache/cache.module.ts index a1ce1966e..348b1ef16 100644 --- a/apps/backend/src/app/cache/cache.module.ts +++ b/apps/backend/src/app/cache/cache.module.ts @@ -5,7 +5,6 @@ import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CacheService } from './cache.service'; import { ResponseCacheSchedulerService } from './response-cache-scheduler.service'; -import { BookletCacheSchedulerService } from './booklet-cache-scheduler.service'; import Persons from '../database/entities/persons.entity'; import { Unit } from '../database/entities/unit.entity'; // eslint-disable-next-line import/no-cycle @@ -29,7 +28,7 @@ import { DatabaseModule } from '../database/database.module'; TypeOrmModule.forFeature([Persons, Unit]), forwardRef(() => DatabaseModule) ], - providers: [CacheService, ResponseCacheSchedulerService, BookletCacheSchedulerService], + providers: [CacheService, ResponseCacheSchedulerService], exports: [CacheService] }) export class CacheModule {} diff --git a/apps/backend/src/app/cache/cache.service.ts b/apps/backend/src/app/cache/cache.service.ts index f8bf632c2..db1c13048 100644 --- a/apps/backend/src/app/cache/cache.service.ts +++ b/apps/backend/src/app/cache/cache.service.ts @@ -5,8 +5,7 @@ import Redis from 'ioredis'; @Injectable() export class CacheService { private readonly logger = new Logger(CacheService.name); - private readonly DEFAULT_TTL = 3600; // 1 hour in seconds - + private readonly DEFAULT_TTL = 86400; // 24 Stunden in Sekunden constructor( @InjectRedis() private readonly redis: Redis ) {} diff --git a/apps/backend/src/app/cache/response-cache-scheduler.service.ts b/apps/backend/src/app/cache/response-cache-scheduler.service.ts index 551c7bf5a..921742787 100644 --- a/apps/backend/src/app/cache/response-cache-scheduler.service.ts +++ b/apps/backend/src/app/cache/response-cache-scheduler.service.ts @@ -20,48 +20,100 @@ export class ResponseCacheSchedulerService { private readonly unitRepository: Repository ) {} - /** - * Scheduled task to cache all possible replay URLs and their responses - * Runs every night at 2:00 AM - */ @Cron(CronExpression.EVERY_DAY_AT_1AM) async cacheAllResponses() { this.logger.log('Starting nightly task to cache all responses'); + const startTime = Date.now(); try { // Get all workspaces with persons const workspaces = await this.getWorkspacesWithPersons(); + this.logger.log(`Found ${workspaces.length} workspaces with test persons`); + + // Process workspaces in parallel with a concurrency limit + const concurrencyLimit = 3; // Adjust based on system resources + const chunks = this.chunkArray(workspaces, concurrencyLimit); + + for (const workspaceChunk of chunks) { + await Promise.all( + workspaceChunk.map(workspace => this.processWorkspace(workspace.workspace_id)) + ); + } + + const duration = (Date.now() - startTime) / 1000; + this.logger.log(`Finished nightly caching of all responses in ${duration.toFixed(2)} seconds`); + } catch (error) { + this.logger.error(`Error in cacheAllResponses: ${error.message}`, error.stack); + } + } + + /** + * Process a single workspace by caching all its responses + */ + private async processWorkspace(workspaceId: number): Promise { + try { + this.logger.log(`Processing workspace ${workspaceId}`); + const workspaceStartTime = Date.now(); + + // Get all test persons and their units in a single query + const personsWithUnits = await this.getPersonsWithUnits(workspaceId); + this.logger.log(`Found ${personsWithUnits.length} persons in workspace ${workspaceId}`); + + // Prepare all cache items to check + const cacheCheckItems: { workspaceId: number; connector: string; unitId: string; cacheKey: string }[] = []; + + for (const person of personsWithUnits) { + for (const unit of person.units) { + const connector = this.createConnector(person, unit.booklet.bookletinfo.name); + const cacheKey = this.cacheService.generateUnitResponseCacheKey(workspaceId, connector, unit.alias); + + cacheCheckItems.push({ + workspaceId, + connector, + unitId: unit.alias, + cacheKey + }); + } + } - for (const workspace of workspaces) { - const workspaceId = workspace.workspace_id; - this.logger.log(`Caching responses for workspace ${workspaceId}`); - - // Get all test persons in this workspace - const persons = await this.personsRepository.find({ - where: { workspace_id: workspaceId, consider: true } - }); - - for (const person of persons) { - // Get all units for this person - const units = await this.getUnitsForPerson(person.id); - - for (const unit of units) { - // Create the connector string (login@code@bookletId) - const connector = this.createConnector(person, unit.booklet.bookletinfo.name); - - try { - // Cache the response - await this.cacheResponse(workspaceId, connector, unit.alias); - } catch (error) { - this.logger.error(`Error caching response for workspace=${workspaceId}, testPerson=${connector}, unitId=${unit.alias}: ${error.message}`, error.stack); - } + // Check which items are already in cache (in batches) + const batchSize = 100; + const itemsToCache: typeof cacheCheckItems = []; + + for (let i = 0; i < cacheCheckItems.length; i += batchSize) { + const batch = cacheCheckItems.slice(i, i + batchSize); + const cacheKeys = batch.map(item => item.cacheKey); + + // Check multiple cache keys at once if Redis supports it + const existsResults = await Promise.all(cacheKeys.map(key => this.cacheService.exists(key))); + + for (let j = 0; j < batch.length; j++) { + if (!existsResults[j]) { + itemsToCache.push(batch[j]); } } } - this.logger.log('Finished nightly caching of all responses'); + this.logger.log(`Found ${itemsToCache.length} items that need caching in workspace ${workspaceId}`); + + // Process items that need caching in smaller parallel batches + const cacheBatchSize = 20; // Adjust based on system resources + const cacheBatches = this.chunkArray(itemsToCache, cacheBatchSize); + + for (const batch of cacheBatches) { + await Promise.all( + batch.map(item => this.cacheResponseWithRetry( + item.workspaceId, + item.connector, + item.unitId + )) + ); + } + + const duration = (Date.now() - workspaceStartTime) / 1000; + this.logger.log(`Finished processing workspace ${workspaceId} in ${duration.toFixed(2)} seconds`); } catch (error) { - this.logger.error(`Error in cacheAllResponses: ${error.message}`, error.stack); + this.logger.error(`Error processing workspace ${workspaceId}: ${error.message}`, error.stack); } } @@ -76,18 +128,6 @@ export class ResponseCacheSchedulerService { .getRawMany(); } - /** - * Get all units for a person - */ - private async getUnitsForPerson(personId: number): Promise { - return this.unitRepository - .createQueryBuilder('unit') - .leftJoinAndSelect('unit.booklet', 'booklet') - .leftJoinAndSelect('booklet.bookletinfo', 'bookletInfo') - .where('booklet.personid = :personId', { personId }) - .getMany(); - } - /** * Create a connector string for a person and booklet */ @@ -118,4 +158,75 @@ export class ResponseCacheSchedulerService { throw error; } } + + /** + * Cache a response with retry logic + */ + private async cacheResponseWithRetry( + workspaceId: number, + connector: string, + unitId: string, + retries = 2 + ): Promise { + try { + await this.cacheResponse(workspaceId, connector, unitId); + } catch (error) { + if (retries > 0) { + this.logger.warn(`Retrying cache operation for workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}. Retries left: ${retries}`); + await new Promise(resolve => { setTimeout(resolve, 1000); }); // Wait 1 second before retry + await this.cacheResponseWithRetry(workspaceId, connector, unitId, retries - 1); + } else { + this.logger.error(`Failed to cache response after retries: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`); + // Don't rethrow to avoid failing the entire batch + } + } + } + + /** + * Get all persons with their units for a workspace in a single optimized query + */ + private async getPersonsWithUnits(workspaceId: number): Promise<(Persons & { units: Unit[] })[]> { + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId, consider: true } + }); + + if (persons.length === 0) { + return []; + } + + const personIds = persons.map(person => person.id); + + const units = await this.unitRepository + .createQueryBuilder('unit') + .leftJoinAndSelect('unit.booklet', 'booklet') + .leftJoinAndSelect('booklet.bookletinfo', 'bookletInfo') + .where('booklet.personid IN (:...personIds)', { personIds }) + .getMany(); + + const unitsByPersonId = new Map(); + for (const unit of units) { + const personId = unit.booklet.personid; + if (!unitsByPersonId.has(personId)) { + unitsByPersonId.set(personId, []); + } + unitsByPersonId.get(personId).push(unit); + } + + // Attach units to each person + return persons.map(person => ({ + ...person, + units: unitsByPersonId.get(person.id) || [] + })); + } + + /** + * Split an array into chunks of specified size + */ + private chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; + } } diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index ea1acfe05..8c512c3aa 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -61,34 +61,7 @@ export class WorkspaceTestResultsService { throw new Error('Both personId and workspaceId are required.'); } - // Generate a cache key for booklet data - const cacheKey = `booklets:${workspaceId}:${personId}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<{ - id: number; - personid: number; - name: string; - size: number; - logs: { id: number; bookletid: number; ts: string; parameter: string, key: string }[]; - sessions: { id: number; browser: string; os: string; screen: string; ts: string }[]; - units: { - id: number; - bookletid: number; - name: string; - alias: string | null; - results: { id: number; unitid: number }[]; - logs: { id: number; unitid: number; ts: string; key: string; parameter: string }[]; - tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; - }[]; - }[]>(cacheKey); - - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached booklet data for person ${personId} in workspace ${workspaceId}`); - return cachedResult; - } - - this.logger.log(`Cache miss for booklet data for person ${personId} in workspace ${workspaceId}`); + this.logger.log(`Fetching booklet data for person ${personId} in workspace ${workspaceId}`); try { this.logger.log( @@ -252,11 +225,6 @@ export class WorkspaceTestResultsService { })) })); - // Store the result in Redis cache (24 hours TTL for booklet data) - const ONE_DAY_SECONDS = 24 * 60 * 60; - await this.cacheService.set(cacheKey, result, ONE_DAY_SECONDS); - this.logger.log(`Cached booklet data for person ${personId} in workspace ${workspaceId}`); - return result; } catch (error) { this.logger.error( @@ -278,17 +246,7 @@ export class WorkspaceTestResultsService { const validPage = Math.max(1, page); // minimum 1 const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT - // Generate a cache key based on the parameters - const cacheKey = `test-results:${workspace_id}:${validPage}:${validLimit}:${searchText || ''}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<[Persons[], number]>(cacheKey); - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); - return cachedResult; - } - - this.logger.log(`Cache miss for test results in workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); + this.logger.log(`Fetching test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); try { const queryBuilder = this.personsRepository.createQueryBuilder('person') @@ -316,11 +274,7 @@ export class WorkspaceTestResultsService { .orderBy('person.code', 'ASC'); const [results, total] = await queryBuilder.getManyAndCount(); - - // Store the result in Redis cache (30 seconds TTL for test results) const result: [Persons[], number] = [results, total]; - await this.cacheService.set(cacheKey, result, 30); - this.logger.log(`Cached test results for workspace ${workspace_id} (page ${validPage}, limit ${validLimit})`); return result; } catch (error) { @@ -332,18 +286,6 @@ export class WorkspaceTestResultsService { async findWorkspaceResponses(workspace_id: number, options?: { page: number; limit: number }): Promise<[ResponseEntity[], number]> { this.logger.log('Returning responses for workspace', workspace_id); - // Generate a cache key based on the parameters - const cacheKey = `workspace-responses:${workspace_id}:${options?.page || 0}:${options?.limit || 0}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<[ResponseEntity[], number]>(cacheKey); - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached workspace responses for workspace ${workspace_id}`); - return cachedResult; - } - - this.logger.log(`Cache miss for workspace responses in workspace ${workspace_id}`); - let result: [ResponseEntity[], number]; if (options) { @@ -369,10 +311,6 @@ export class WorkspaceTestResultsService { result = [responses, responses.length]; } - // Store the result in Redis cache (45 seconds TTL for workspace responses) - await this.cacheService.set(cacheKey, result, 45); - this.logger.log(`Cached workspace responses for workspace ${workspace_id}`); - return result; } @@ -497,22 +435,9 @@ export class WorkspaceTestResultsService { return result; } - private readonly RESPONSES_CACHE_TTL_SECONDS = 60; // 1 minute cache TTL - async getResponsesByStatus(workspace_id: number, status: string, options?: { page: number; limit: number }): Promise<[ResponseEntity[], number]> { this.logger.log(`Getting responses with status ${status} for workspace ${workspace_id}`); - const cacheKey = `responses:status:${workspace_id}:${status}:${options?.page || 0}:${options?.limit || 0}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<[ResponseEntity[], number]>(cacheKey); - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached responses for status ${status} (workspace ${workspace_id})`); - return cachedResult; - } - - this.logger.log(`Cache miss for responses with status ${status} (workspace ${workspace_id})`); - try { const queryBuilder = this.responseRepository.createQueryBuilder('response') .leftJoinAndSelect('response.unit', 'unit') @@ -549,10 +474,6 @@ export class WorkspaceTestResultsService { this.logger.log(`Found ${result[0].length} responses with status ${status} for workspace ${workspace_id}`); } - // Store the result in Redis cache - await this.cacheService.set(cacheKey, result, this.RESPONSES_CACHE_TTL_SECONDS); - this.logger.log(`Cached responses with status ${status} for workspace ${workspace_id}`); - return result; } catch (error) { this.logger.error(`Error getting responses by status: ${error.message}`); @@ -876,38 +797,7 @@ export class WorkspaceTestResultsService { const limit = options.limit || 10; const skip = (page - 1) * limit; - // Generate a cache key based on the search parameters and pagination - const cacheKey = `search-responses:${workspaceId}:${JSON.stringify(searchParams)}:${page}:${limit}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<{ - data: { - responseId: number; - variableId: string; - value: string; - status: string; - code?: number; - score?: number; - codedStatus?: string; - unitId: number; - unitName: string; - unitAlias: string | null; - bookletId: number; - bookletName: string; - personId: number; - personLogin: string; - personCode: string; - personGroup: string; - }[]; - total: number; - }>(cacheKey); - - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached search results for workspace ${workspaceId}`); - return cachedResult; - } - - this.logger.log(`Cache miss for search results in workspace ${workspaceId}`); + this.logger.log(`Searching for responses in workspace ${workspaceId}`); try { this.logger.log( @@ -983,10 +873,6 @@ export class WorkspaceTestResultsService { const result = { data, total }; - const ONE_DAY_SECONDS = 24 * 60 * 60; - await this.cacheService.set(cacheKey, result, ONE_DAY_SECONDS); - this.logger.log(`Cached search results for workspace ${workspaceId}`); - return result; } catch (error) { this.logger.error( @@ -1025,33 +911,7 @@ export class WorkspaceTestResultsService { const limit = options.limit || 10; const skip = (page - 1) * limit; - // Generate a cache key based on the parameters - const cacheKey = `units-by-name:${workspaceId}:${unitName}:${page}:${limit}`; - - // Check if data is in Redis cache - const cachedResult = await this.cacheService.get<{ - data: { - unitId: number; - unitName: string; - unitAlias: string | null; - bookletId: number; - bookletName: string; - personId: number; - personLogin: string; - personCode: string; - personGroup: string; - tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; - responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; - }[]; - total: number; - }>(cacheKey); - - if (cachedResult) { - this.logger.log(`Cache hit: Returning cached units by name for workspace ${workspaceId}, unitName: ${unitName}`); - return cachedResult; - } - - this.logger.log(`Cache miss for units by name in workspace ${workspaceId}, unitName: ${unitName}`); + this.logger.log(`Finding units by name for workspace ${workspaceId}, unitName: ${unitName}`); try { this.logger.log( @@ -1122,10 +982,6 @@ export class WorkspaceTestResultsService { data = Array.from(uniqueMap.values()); const result = { data, total: data.length }; - // Store the result in Redis cache (60 seconds TTL for units by name) - await this.cacheService.set(cacheKey, result, 60); - this.logger.log(`Cached units by name for workspace ${workspaceId}, unitName: ${unitName}`); - return result; } catch (error) { this.logger.error( From 2f30a3fc6df7340de003286298168442eacdcde8 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:06:12 +0200 Subject: [PATCH 05/14] Manage coding jobs with variables and bundles --- apps/backend/src/app/admin/admin.module.ts | 16 +- .../admin/workspace/coding-job.controller.ts | 310 +++++++++++++++ .../workspace/variable-bundle.controller.ts | 341 ++++++++++++++++ .../src/app/database/database.module.ts | 17 +- .../database/entities/coding-job.entity.ts | 71 ++++ .../entities/variable-bundle.entity.ts | 67 ++++ .../app/database/entities/variable.entity.ts | 37 ++ .../database/services/coding-job.service.ts | 326 ++++++++++++++++ .../services/variable-bundle-group.service.ts | 276 +++++++++++++ .../services/variable-bundle.service.ts | 194 +++++++++ .../services/workspace-users.service.ts | 30 +- .../coding-job-dialog.component.html | 284 ++++++++++++++ .../coding-job-dialog.component.scss | 178 +++++++++ .../coding-job-dialog.component.ts | 312 +++++++++++++++ .../coding-jobs/coding-jobs.component.html | 19 +- .../coding-jobs/coding-jobs.component.scss | 17 + .../coding-jobs/coding-jobs.component.ts | 202 +++++++++- .../coding-management-manual.component.html | 16 +- .../coding-management-manual.component.scss | 32 +- .../coding-management-manual.component.ts | 52 ++- .../my-coding-jobs.component.ts | 81 ++-- .../variable-bundle-dialog.component.html | 183 +++++++++ .../variable-bundle-dialog.component.scss | 147 +++++++ .../variable-bundle-dialog.component.ts | 246 ++++++++++++ .../variable-bundle-manager.component.html | 103 +++++ .../variable-bundle-manager.component.scss | 79 ++++ .../variable-bundle-manager.component.ts | 212 ++++++++++ .../src/app/coding/models/coding-job.model.ts | 16 + .../src/app/coding/services/coder.service.ts | 207 ++++++---- .../app/coding/services/coding-job.service.ts | 252 ++++++++++++ .../services/variable-bundle.service.ts | 369 ++++++++++++++++++ .../changelog/coding-box.changelog-0.12.0.sql | 94 +++++ .../changelog/coding-box.changelog-root.xml | 1 + 33 files changed, 4660 insertions(+), 127 deletions(-) create mode 100644 apps/backend/src/app/admin/workspace/coding-job.controller.ts create mode 100644 apps/backend/src/app/admin/workspace/variable-bundle.controller.ts create mode 100644 apps/backend/src/app/database/entities/coding-job.entity.ts create mode 100644 apps/backend/src/app/database/entities/variable-bundle.entity.ts create mode 100644 apps/backend/src/app/database/entities/variable.entity.ts create mode 100644 apps/backend/src/app/database/services/coding-job.service.ts create mode 100644 apps/backend/src/app/database/services/variable-bundle-group.service.ts create mode 100644 apps/backend/src/app/database/services/variable-bundle.service.ts create mode 100644 apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html create mode 100644 apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss create mode 100644 apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts create mode 100644 apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html create mode 100644 apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.scss create mode 100644 apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.ts create mode 100644 apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html create mode 100644 apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.scss create mode 100644 apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts create mode 100644 apps/frontend/src/app/coding/services/coding-job.service.ts create mode 100644 apps/frontend/src/app/coding/services/variable-bundle.service.ts create mode 100644 database/changelog/coding-box.changelog-0.12.0.sql diff --git a/apps/backend/src/app/admin/admin.module.ts b/apps/backend/src/app/admin/admin.module.ts index e8e27a367..a9f4d442f 100755 --- a/apps/backend/src/app/admin/admin.module.ts +++ b/apps/backend/src/app/admin/admin.module.ts @@ -25,14 +25,20 @@ import { MissingsProfilesController } from './workspace/missings-profiles.contro import { BookletInfoService } from '../database/services/booklet-info.service'; import { UnitInfoService } from '../database/services/unit-info.service'; import FileUpload from '../database/entities/file_upload.entity'; +import { Variable } from '../database/entities/variable.entity'; +import { VariableBundle } from '../database/entities/variable-bundle.entity'; import { ReplayStatisticsController } from './replay-statistics/replay-statistics.controller'; +import { VariableBundleController } from './workspace/variable-bundle.controller'; +import { VariableBundleService } from '../database/services/variable-bundle.service'; +import { VariableBundleGroupService } from '../database/services/variable-bundle-group.service'; +import { CodingJobController } from './workspace/coding-job.controller'; @Module({ imports: [ DatabaseModule, AuthModule, HttpModule, - TypeOrmModule.forFeature([FileUpload]) + TypeOrmModule.forFeature([FileUpload, Variable, VariableBundle]) ], controllers: [ UsersController, @@ -54,11 +60,15 @@ import { ReplayStatisticsController } from './replay-statistics/replay-statistic BookletInfoController, UnitInfoController, MissingsProfilesController, - ReplayStatisticsController + ReplayStatisticsController, + VariableBundleController, + CodingJobController ], providers: [ BookletInfoService, - UnitInfoService + UnitInfoService, + VariableBundleService, + VariableBundleGroupService ] }) export class AdminModule {} diff --git a/apps/backend/src/app/admin/workspace/coding-job.controller.ts b/apps/backend/src/app/admin/workspace/coding-job.controller.ts new file mode 100644 index 000000000..76e4ae06c --- /dev/null +++ b/apps/backend/src/app/admin/workspace/coding-job.controller.ts @@ -0,0 +1,310 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { CodingJobService } from '../../database/services/coding-job.service'; +import { CodingJob } from '../../database/entities/coding-job.entity'; +import WorkspaceUser from '../../database/entities/workspace_user.entity'; + +@ApiTags('Admin Workspace Coding Jobs') +@Controller('admin/workspace') +export class CodingJobController { + constructor( + private codingJobService: CodingJobService + ) {} + + @Post(':workspace_id/coding-jobs') + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a coding job', description: 'Creates a new coding job in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiBody({ + schema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', description: 'Name of the coding job' }, + description: { type: 'string', description: 'Description of the coding job' }, + variableBundleIds: { type: 'array', items: { type: 'number' }, description: 'IDs of variable bundles to include' }, + variableBundleGroupIds: { type: 'array', items: { type: 'number' }, description: 'IDs of variable bundle groups to include' } + } + } + }) + @ApiCreatedResponse({ description: 'Coding job created successfully', type: CodingJob }) + @ApiBadRequestResponse({ description: 'Invalid input parameters' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async createCodingJob( + @Param('workspace_id') workspaceId: number, + @Body() body: { + name: string; + description?: string; + variableBundleIds?: number[]; + variableBundleGroupIds?: number[]; + } + ): Promise { + if (!body.name) { + throw new BadRequestException('Name is required'); + } + + return this.codingJobService.createCodingJob( + workspaceId, + body.name, + body.description, + body.variableBundleIds || [], + body.variableBundleGroupIds || [] + ); + } + + @Get(':workspace_id/coding-jobs') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get coding jobs', description: 'Gets all coding jobs in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'page', required: false, description: 'Page number for pagination', type: Number + }) + @ApiQuery({ + name: 'limit', required: false, description: 'Number of items per page', type: Number + }) + @ApiOkResponse({ + description: 'Coding jobs retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/CodingJob' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getCodingJobs( + @Param('workspace_id') workspaceId: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ): Promise<{ data: CodingJob[]; total: number; page: number; limit: number }> { + const [jobs, total] = await this.codingJobService.getCodingJobs(workspaceId, { page, limit }); + return { + data: jobs, + total, + page, + limit + }; + } + + @Get(':workspace_id/coding-jobs/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get a coding job', description: 'Gets a coding job by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the coding job' }) + @ApiOkResponse({ description: 'Coding job retrieved successfully', type: CodingJob }) + @ApiNotFoundResponse({ description: 'Coding job not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getCodingJob( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number + ): Promise { + const codingJob = await this.codingJobService.getCodingJob(workspaceId, id); + if (!codingJob) { + throw new NotFoundException(`Coding job with ID ${id} not found in workspace ${workspaceId}`); + } + return codingJob; + } + + @Put(':workspace_id/coding-jobs/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a coding job', description: 'Updates a coding job by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the coding job' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the coding job' }, + description: { type: 'string', description: 'Description of the coding job' }, + status: { type: 'string', description: 'Status of the coding job' }, + variableBundleIds: { type: 'array', items: { type: 'number' }, description: 'IDs of variable bundles to include' }, + variableBundleGroupIds: { type: 'array', items: { type: 'number' }, description: 'IDs of variable bundle groups to include' } + } + } + }) + @ApiOkResponse({ description: 'Coding job updated successfully', type: CodingJob }) + @ApiNotFoundResponse({ description: 'Coding job not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async updateCodingJob( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number, + @Body() body: { + name?: string; + description?: string; + status?: string; + variableBundleIds?: number[]; + variableBundleGroupIds?: number[]; + } + ): Promise { + const codingJob = await this.codingJobService.updateCodingJob( + workspaceId, + id, + body.name, + body.description, + body.status, + body.variableBundleIds, + body.variableBundleGroupIds + ); + if (!codingJob) { + throw new NotFoundException(`Coding job with ID ${id} not found in workspace ${workspaceId}`); + } + return codingJob; + } + + @Delete(':workspace_id/coding-jobs/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a coding job', description: 'Deletes a coding job by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the coding job' }) + @ApiOkResponse({ description: 'Coding job deleted successfully', type: Boolean }) + @ApiNotFoundResponse({ description: 'Coding job not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteCodingJob( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number + ): Promise<{ success: boolean }> { + const success = await this.codingJobService.deleteCodingJob(workspaceId, id); + if (!success) { + throw new NotFoundException(`Coding job with ID ${id} not found in workspace ${workspaceId}`); + } + return { success }; + } + + @Post(':workspace_id/coding-jobs/:id/assign/:coder_id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Assign a coder to a coding job', description: 'Assigns a coder to a coding job in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the coding job' }) + @ApiParam({ name: 'coder_id', required: true, description: 'ID of the coder' }) + @ApiOkResponse({ description: 'Coder assigned successfully', type: CodingJob }) + @ApiNotFoundResponse({ description: 'Coding job or coder not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async assignCoder( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number, + @Param('coder_id') coderId: number + ): Promise { + const codingJob = await this.codingJobService.assignCoder(workspaceId, id, coderId); + if (!codingJob) { + throw new NotFoundException(`Coding job with ID ${id} or coder with ID ${coderId} not found in workspace ${workspaceId}`); + } + return codingJob; + } + + @Delete(':workspace_id/coding-jobs/:id/assign/:coder_id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Unassign a coder from a coding job', description: 'Unassigns a coder from a coding job in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the coding job' }) + @ApiParam({ name: 'coder_id', required: true, description: 'ID of the coder' }) + @ApiOkResponse({ description: 'Coder unassigned successfully', type: CodingJob }) + @ApiNotFoundResponse({ description: 'Coding job not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async unassignCoder( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number, + @Param('coder_id') coderId: number + ): Promise { + const codingJob = await this.codingJobService.unassignCoder(workspaceId, id, coderId); + if (!codingJob) { + throw new NotFoundException(`Coding job with ID ${id} not found in workspace ${workspaceId}`); + } + return codingJob; + } + + @Get(':workspace_id/coding-jobs/:id/coders') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get coders by coding job', description: 'Gets all coders assigned to a coding job in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the coding job' }) + @ApiOkResponse({ + description: 'Coders retrieved successfully', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + userId: { type: 'number' }, + workspaceId: { type: 'number' }, + accessLevel: { type: 'number' }, + username: { type: 'string' } + } + } + }, + total: { type: 'number' } + } + } + }) + @ApiNotFoundResponse({ description: 'Coding job not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getCodersByCodingJob( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number + ): Promise<{ data: WorkspaceUser[]; total: number }> { + const codingJob = await this.codingJobService.getCodingJob(workspaceId, id); + if (!codingJob) { + throw new NotFoundException(`Coding job with ID ${id} not found in workspace ${workspaceId}`); + } + + return { + data: codingJob.assignedCoders || [], + total: codingJob.assignedCoders?.length || 0 + }; + } + + @Get(':workspace_id/coders/:coder_id/coding-jobs') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get coding jobs by coder', description: 'Gets all coding jobs assigned to a coder in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'coder_id', required: true, description: 'ID of the coder' }) + @ApiOkResponse({ + description: 'Coding jobs retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/CodingJob' } } + } + } + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getCodingJobsByCoder( + @Param('workspace_id') workspaceId: number, + @Param('coder_id') coderId: number + ): Promise<{ data: CodingJob[] }> { + const jobs = await this.codingJobService.getCodingJobsByCoder(workspaceId, coderId); + return { + data: jobs + }; + } +} diff --git a/apps/backend/src/app/admin/workspace/variable-bundle.controller.ts b/apps/backend/src/app/admin/workspace/variable-bundle.controller.ts new file mode 100644 index 000000000..350ee3805 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/variable-bundle.controller.ts @@ -0,0 +1,341 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { WorkspaceGuard } from './workspace.guard'; +import { VariableBundleService } from '../../database/services/variable-bundle.service'; +import { VariableBundleGroupService } from '../../database/services/variable-bundle-group.service'; +import { Variable } from '../../database/entities/variable.entity'; +import { VariableBundle } from '../../database/entities/variable-bundle.entity'; + +@ApiTags('Admin Workspace Variable Bundles') +@Controller('admin/workspace') +export class VariableBundleController { + constructor( + private variableBundleService: VariableBundleService, + private variableBundleGroupService: VariableBundleGroupService + ) {} + + // Variable Bundle endpoints + + @Post(':workspace_id/variable-bundles') + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a variable bundle', description: 'Creates a new variable bundle in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiBody({ + schema: { + type: 'object', + required: ['unitName', 'variableId'], + properties: { + unitName: { type: 'string', description: 'Name of the unit' }, + variableId: { type: 'string', description: 'ID of the variable' } + } + } + }) + @ApiCreatedResponse({ description: 'Variable bundle created successfully', type: Variable }) + @ApiBadRequestResponse({ description: 'Invalid input parameters' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async createVariableBundle( + @Param('workspace_id') workspaceId: number, + @Body() body: { + unitName: string; + variableId: string; + } + ): Promise { + if (!body.unitName || !body.variableId) { + throw new BadRequestException('Unit name and variable ID are required'); + } + + return this.variableBundleService.createVariableBundle( + workspaceId, + body.unitName, + body.variableId + ); + } + + @Get(':workspace_id/variable-bundles') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get variable bundles', description: 'Gets all variable bundles in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiQuery({ name: 'page', required: false, description: 'Page number for pagination', type: Number }) + @ApiQuery({ name: 'limit', required: false, description: 'Number of items per page', type: Number }) + @ApiOkResponse({ + description: 'Variable bundles retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/Variable' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getVariableBundles( + @Param('workspace_id') workspaceId: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ): Promise<{ data: Variable[]; total: number; page: number; limit: number }> { + const [bundles, total] = await this.variableBundleService.getVariableBundles(workspaceId, { page, limit }); + return { + data: bundles, + total, + page, + limit + }; + } + + @Get(':workspace_id/variable-bundles/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get a variable bundle', description: 'Gets a variable bundle by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the variable bundle' }) + @ApiOkResponse({ description: 'Variable retrieved successfully', type: Variable }) + @ApiNotFoundResponse({ description: 'Variable not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getVariableBundle( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number + ): Promise { + const variableBundle = await this.variableBundleService.getVariableBundle(workspaceId, id); + if (!variableBundle) { + throw new NotFoundException(`Variable bundle with ID ${id} not found in workspace ${workspaceId}`); + } + return variableBundle; + } + + @Delete(':workspace_id/variable-bundles/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a variable bundle', description: 'Deletes a variable bundle by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the variable bundle' }) + @ApiOkResponse({ description: 'Variable bundle deleted successfully', type: Boolean }) + @ApiNotFoundResponse({ description: 'Variable bundle not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteVariableBundle( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number + ): Promise<{ success: boolean }> { + const success = await this.variableBundleService.deleteVariableBundle(workspaceId, id); + if (!success) { + throw new NotFoundException(`Variable bundle with ID ${id} not found in workspace ${workspaceId}`); + } + return { success }; + } + + // Variable Bundle Group endpoints + + @Post(':workspace_id/variable-bundle-groups') + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a variable bundle group', description: 'Creates a new variable bundle group in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiBody({ + schema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', description: 'Name of the variable bundle group' }, + description: { type: 'string', description: 'Description of the variable bundle group' }, + variableBundleIds: { type: 'array', items: { type: 'number' }, description: 'IDs of variable bundles to include' } + } + } + }) + @ApiCreatedResponse({ description: 'Variable bundle created successfully', type: VariableBundle }) + @ApiBadRequestResponse({ description: 'Invalid input parameters' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async createVariableBundleGroup( + @Param('workspace_id') workspaceId: number, + @Body() body: { + name: string; + description?: string; + variableBundleIds?: number[]; + } + ): Promise { + if (!body.name) { + throw new BadRequestException('Name is required'); + } + + return this.variableBundleGroupService.createVariableBundleGroup( + workspaceId, + body.name, + body.description, + body.variableBundleIds || [] + ); + } + + @Get(':workspace_id/variable-bundle-groups') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get variable bundle groups', description: 'Gets all variable bundle groups in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiQuery({ name: 'page', required: false, description: 'Page number for pagination', type: Number }) + @ApiQuery({ name: 'limit', required: false, description: 'Number of items per page', type: Number }) + @ApiOkResponse({ + description: 'Variable bundle groups retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/VariableBundle' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getVariableBundleGroups( + @Param('workspace_id') workspaceId: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ): Promise<{ data: VariableBundle[]; total: number; page: number; limit: number }> { + const [groups, total] = await this.variableBundleGroupService.getVariableBundleGroups(workspaceId, { page, limit }); + return { + data: groups, + total, + page, + limit + }; + } + + @Get(':workspace_id/variable-bundle-groups/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get a variable bundle group', description: 'Gets a variable bundle group by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the variable bundle group' }) + @ApiOkResponse({ description: 'Variable bundle retrieved successfully', type: VariableBundle }) + @ApiNotFoundResponse({ description: 'Variable bundle group not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async getVariableBundleGroup( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number + ): Promise { + const variableBundleGroup = await this.variableBundleGroupService.getVariableBundleGroup(workspaceId, id); + if (!variableBundleGroup) { + throw new NotFoundException(`Variable bundle group with ID ${id} not found in workspace ${workspaceId}`); + } + return variableBundleGroup; + } + + @Put(':workspace_id/variable-bundle-groups/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a variable bundle group', description: 'Updates a variable bundle group by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the variable bundle group' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the variable bundle group' }, + description: { type: 'string', description: 'Description of the variable bundle group' }, + variableBundleIds: { type: 'array', items: { type: 'number' }, description: 'IDs of variable bundles to include' } + } + } + }) + @ApiOkResponse({ description: 'Variable bundle updated successfully', type: VariableBundle }) + @ApiNotFoundResponse({ description: 'Variable bundle group not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async updateVariableBundleGroup( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number, + @Body() body: { + name?: string; + description?: string; + variableBundleIds?: number[]; + } + ): Promise { + const variableBundleGroup = await this.variableBundleGroupService.updateVariableBundleGroup( + workspaceId, + id, + body.name, + body.description, + body.variableBundleIds + ); + if (!variableBundleGroup) { + throw new NotFoundException(`Variable bundle group with ID ${id} not found in workspace ${workspaceId}`); + } + return variableBundleGroup; + } + + @Delete(':workspace_id/variable-bundle-groups/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a variable bundle group', description: 'Deletes a variable bundle group by ID in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the variable bundle group' }) + @ApiOkResponse({ description: 'Variable bundle group deleted successfully', type: Boolean }) + @ApiNotFoundResponse({ description: 'Variable bundle group not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async deleteVariableBundleGroup( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number + ): Promise<{ success: boolean }> { + const success = await this.variableBundleGroupService.deleteVariableBundleGroup(workspaceId, id); + if (!success) { + throw new NotFoundException(`Variable bundle group with ID ${id} not found in workspace ${workspaceId}`); + } + return { success }; + } + + @Post(':workspace_id/variable-bundle-groups/:id/variables/:variable_id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Add a variable bundle to a group', description: 'Adds a variable bundle to a variable bundle group in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the variable bundle group' }) + @ApiParam({ name: 'variable_id', required: true, description: 'ID of the variable bundle' }) + @ApiOkResponse({ description: 'Variable added to bundle successfully', type: VariableBundle }) + @ApiNotFoundResponse({ description: 'Variable bundle group or variable bundle not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async addVariableBundleToGroup( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number, + @Param('variable_id') variableId: number + ): Promise { + const variableBundleGroup = await this.variableBundleGroupService.addVariableBundleToGroup(workspaceId, id, variableId); + if (!variableBundleGroup) { + throw new NotFoundException(`Variable bundle group with ID ${id} or variable bundle with ID ${variableId} not found in workspace ${workspaceId}`); + } + return variableBundleGroup; + } + + @Delete(':workspace_id/variable-bundle-groups/:id/variables/:variable_id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Remove a variable bundle from a group', description: 'Removes a variable bundle from a variable bundle group in the specified workspace' }) + @ApiParam({ name: 'workspace_id', required: true, description: 'ID of the workspace' }) + @ApiParam({ name: 'id', required: true, description: 'ID of the variable bundle group' }) + @ApiParam({ name: 'variable_id', required: true, description: 'ID of the variable bundle' }) + @ApiOkResponse({ description: 'Variable removed from bundle successfully', type: VariableBundle }) + @ApiNotFoundResponse({ description: 'Variable bundle group not found' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async removeVariableBundleFromGroup( + @Param('workspace_id') workspaceId: number, + @Param('id') id: number, + @Param('variable_id') variableId: number + ): Promise { + const variableBundleGroup = await this.variableBundleGroupService.removeVariableBundleFromGroup(workspaceId, id, variableId); + if (!variableBundleGroup) { + throw new NotFoundException(`Variable bundle group with ID ${id} not found in workspace ${workspaceId}`); + } + return variableBundleGroup; + } +} diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index c011c947b..63e6383eb 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -42,11 +42,15 @@ import { VariableAnalysisService } from './services/variable-analysis.service'; import { JobService } from './services/job.service'; import { ValidationTaskService } from './services/validation-task.service'; import { Job } from './entities/job.entity'; +import { CodingJob } from './entities/coding-job.entity'; import { VariableAnalysisJob } from './entities/variable-analysis-job.entity'; import { ValidationTask } from './entities/validation-task.entity'; import { Setting } from './entities/setting.entity'; import { ReplayStatistics } from './entities/replay-statistics.entity'; +import { Variable } from './entities/variable.entity'; +import { VariableBundle } from './entities/variable-bundle.entity'; import { ReplayStatisticsService } from './services/replay-statistics.service'; +import { CodingJobService } from './services/coding-job.service'; // eslint-disable-next-line import/no-cycle import { JobQueueModule } from '../job-queue/job-queue.module'; // eslint-disable-next-line import/no-cycle @@ -82,7 +86,7 @@ import { CacheModule } from '../cache/cache.module'; password: configService.get('POSTGRES_PASSWORD'), database: configService.get('POSTGRES_DB'), entities: [BookletInfo, Booklet, Session, BookletLog, Unit, UnitLog, UnitLastState, ResponseEntity, - User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, VariableAnalysisJob, ValidationTask, Setting, ReplayStatistics + User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, CodingJob, VariableAnalysisJob, ValidationTask, Setting, ReplayStatistics, Variable, VariableBundle ], synchronize: false }), @@ -110,10 +114,13 @@ import { CacheModule } from '../cache/cache.module'; UnitNote, JournalEntry, Job, + CodingJob, VariableAnalysisJob, ValidationTask, Setting, - ReplayStatistics + ReplayStatistics, + Variable, + VariableBundle ]) ], providers: [ @@ -136,7 +143,8 @@ import { CacheModule } from '../cache/cache.module'; VariableAnalysisService, JobService, ValidationTaskService, - ReplayStatisticsService + ReplayStatisticsService, + CodingJobService ], exports: [ User, @@ -165,7 +173,8 @@ import { CacheModule } from '../cache/cache.module'; VariableAnalysisService, JobService, ValidationTaskService, - ReplayStatisticsService + ReplayStatisticsService, + CodingJobService ] }) export class DatabaseModule {} diff --git a/apps/backend/src/app/database/entities/coding-job.entity.ts b/apps/backend/src/app/database/entities/coding-job.entity.ts new file mode 100644 index 000000000..d3ee11127 --- /dev/null +++ b/apps/backend/src/app/database/entities/coding-job.entity.ts @@ -0,0 +1,71 @@ +import { + Column, + ChildEntity, + JoinTable, + ManyToMany +} from 'typeorm'; +import { Job } from './job.entity'; +import WorkspaceUser from './workspace_user.entity'; +import { Variable } from './variable.entity'; +import { VariableBundle } from './variable-bundle.entity'; + +@ChildEntity('coding-job') +export class CodingJob extends Job { + @Column() + name: string; + + @Column({ nullable: true }) + description?: string; + + /** + * Many-to-many relationship with workspace users (coders) + * This represents the coders assigned to this job + */ + @ManyToMany(() => WorkspaceUser) + @JoinTable({ + name: 'coding_job_coders', + joinColumn: { + name: 'coding_job_id', + referencedColumnName: 'id' + }, + inverseJoinColumn: { + name: 'coder_id', + referencedColumnName: 'userId' + } + }) + assignedCoders: WorkspaceUser[]; + + /** + * Many-to-many relationship with variables + */ + @ManyToMany('Variable', 'codingJobs') + @JoinTable({ + name: 'coding_job_variable', + joinColumn: { + name: 'coding_job_id', + referencedColumnName: 'id' + }, + inverseJoinColumn: { + name: 'variable_id', + referencedColumnName: 'id' + } + }) + variables: Variable[]; + + /** + * Many-to-many relationship with variable bundles + */ + @ManyToMany('VariableBundle', 'codingJobs') + @JoinTable({ + name: 'coding_job_variable_bundle', + joinColumn: { + name: 'coding_job_id', + referencedColumnName: 'id' + }, + inverseJoinColumn: { + name: 'variable_bundle_id', + referencedColumnName: 'id' + } + }) + variableBundles: VariableBundle[]; +} diff --git a/apps/backend/src/app/database/entities/variable-bundle.entity.ts b/apps/backend/src/app/database/entities/variable-bundle.entity.ts new file mode 100644 index 000000000..3da448198 --- /dev/null +++ b/apps/backend/src/app/database/entities/variable-bundle.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, + JoinTable +} from 'typeorm'; +import type { Variable } from './variable.entity'; +import type { CodingJob } from './coding-job.entity'; + +@Entity('variable_bundle') +export class VariableBundle { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'workspace_id' }) + workspaceId: number; + + @Column() + name: string; + + @Column({ nullable: true }) + description?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + /** + * Many-to-many relationship with variables (formerly variable bundles) + */ + @ManyToMany('Variable', 'bundles') + @JoinTable({ + name: 'variable_bundle_variables', + joinColumn: { + name: 'bundle_id', + referencedColumnName: 'id' + }, + inverseJoinColumn: { + name: 'variable_id', + referencedColumnName: 'id' + } + }) + variables: Variable[]; + + /** + * Many-to-many relationship with coding jobs + * This is the inverse side of the relationship + */ + @ManyToMany('CodingJob', 'variableBundles') + @JoinTable({ + name: 'coding_job_variable_bundle', + joinColumn: { + name: 'variable_bundle_id', + referencedColumnName: 'id' + }, + inverseJoinColumn: { + name: 'coding_job_id', + referencedColumnName: 'id' + } + }) + codingJobs: CodingJob[]; +} diff --git a/apps/backend/src/app/database/entities/variable.entity.ts b/apps/backend/src/app/database/entities/variable.entity.ts new file mode 100644 index 000000000..8aac66533 --- /dev/null +++ b/apps/backend/src/app/database/entities/variable.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToMany +} from 'typeorm'; +import type { VariableBundle } from './variable-bundle.entity'; +import type { CodingJob } from './coding-job.entity'; + +@Entity('variable') +export class Variable { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'workspace_id' }) + workspaceId: number; + + @Column({ name: 'unit_name' }) + unitName: string; + + @Column({ name: 'variable_id' }) + variableId: string; + + /** + * Many-to-many relationship with coding jobs + * This is the inverse side of the relationship + */ + @ManyToMany('CodingJob', 'variables') + codingJobs: CodingJob[]; + + /** + * Many-to-many relationship with variable bundles + * This is the inverse side of the relationship + */ + @ManyToMany('VariableBundle', 'variables') + bundles: VariableBundle[]; +} diff --git a/apps/backend/src/app/database/services/coding-job.service.ts b/apps/backend/src/app/database/services/coding-job.service.ts new file mode 100644 index 000000000..89d7efadf --- /dev/null +++ b/apps/backend/src/app/database/services/coding-job.service.ts @@ -0,0 +1,326 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { CodingJob } from '../entities/coding-job.entity'; +import { Variable } from '../entities/variable.entity'; +import { VariableBundle } from '../entities/variable-bundle.entity'; +import WorkspaceUser from '../entities/workspace_user.entity'; + +@Injectable() +export class CodingJobService { + private readonly logger = new Logger(CodingJobService.name); + + constructor( + @InjectRepository(CodingJob) + private codingJobRepository: Repository, + @InjectRepository(Variable) + private variableBundleRepository: Repository, + @InjectRepository(VariableBundle) + private variableBundleGroupRepository: Repository, + @InjectRepository(WorkspaceUser) + private workspaceUserRepository: Repository + ) {} + + /** + * Creates a new coding job + * @param workspaceId The ID of the workspace + * @param name The name of the coding job + * @param description The description of the coding job + * @param variableBundleIds The IDs of the variable bundles to include + * @param variableBundleGroupIds The IDs of the variable bundle groups to include + */ + async createCodingJob( + workspaceId: number, + name: string, + description?: string, + variableBundleIds: number[] = [], + variableBundleGroupIds: number[] = [] + ): Promise { + this.logger.log(`Creating coding job "${name}" for workspace ${workspaceId}`); + + // Create the coding job + const codingJob = this.codingJobRepository.create({ + workspace_id: workspaceId, + name, + description, + status: 'pending' + }); + + // Save the coding job to get an ID + const savedCodingJob = await this.codingJobRepository.save(codingJob); + + // If variable bundles are specified, associate them with the coding job + if (variableBundleIds.length > 0) { + const variableBundles = await this.variableBundleRepository.find({ + where: { + id: In(variableBundleIds), + workspaceId + } + }); + savedCodingJob.variables = variableBundles; + } + + // If variable bundle groups are specified, associate them with the coding job + if (variableBundleGroupIds.length > 0) { + const variableBundleGroups = await this.variableBundleGroupRepository.find({ + where: { + id: In(variableBundleGroupIds), + workspaceId + } + }); + savedCodingJob.variableBundles = variableBundleGroups; + } + + // Save the coding job with the associations + return this.codingJobRepository.save(savedCodingJob); + } + + /** + * Gets a coding job by ID + * @param workspaceId The ID of the workspace + * @param id The ID of the coding job + */ + async getCodingJob(workspaceId: number, id: number): Promise { + this.logger.log(`Getting coding job ${id} for workspace ${workspaceId}`); + + return this.codingJobRepository.findOne({ + where: { + id, + workspace_id: workspaceId + }, + relations: ['variables', 'variableBundles', 'assignedCoders'] + }); + } + + /** + * Gets all coding jobs for a workspace + * @param workspaceId The ID of the workspace + * @param options Pagination options + */ + async getCodingJobs( + workspaceId: number, + options?: { page: number; limit: number } + ): Promise<[CodingJob[], number]> { + this.logger.log(`Getting coding jobs for workspace ${workspaceId}`); + + try { + if (options) { + const { page, limit } = options; + const MAX_LIMIT = 500; + const validPage = Math.max(1, page); // minimum 1 + const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT + + const [jobs, total] = await this.codingJobRepository.findAndCount({ + where: { workspace_id: workspaceId }, + skip: (validPage - 1) * validLimit, + take: validLimit, + order: { id: 'ASC' }, + relations: ['variables', 'variableBundles', 'assignedCoders'] + }); + + this.logger.log(`Found ${jobs.length} coding job(s) (page ${validPage}, limit ${validLimit}, total ${total}) for workspace ID: ${workspaceId}`); + return [jobs, total]; + } + + const jobs = await this.codingJobRepository.find({ + where: { workspace_id: workspaceId }, + order: { id: 'ASC' }, + relations: ['variables', 'variableBundles', 'assignedCoders'] + }); + + this.logger.log(`Found ${jobs.length} coding job(s) for workspace ID: ${workspaceId}`); + return [jobs, jobs.length]; + } catch (error) { + this.logger.error(`Failed to retrieve coding jobs for workspace ID: ${workspaceId}`, error.stack); + throw new Error('Could not retrieve coding jobs'); + } + } + + /** + * Updates a coding job + * @param workspaceId The ID of the workspace + * @param id The ID of the coding job + * @param name The name of the coding job + * @param description The description of the coding job + * @param status The status of the coding job + * @param variableBundleIds The IDs of the variable bundles to include + * @param variableBundleGroupIds The IDs of the variable bundle groups to include + */ + async updateCodingJob( + workspaceId: number, + id: number, + name?: string, + description?: string, + status?: string, + variableBundleIds?: number[], + variableBundleGroupIds?: number[] + ): Promise { + this.logger.log(`Updating coding job ${id} for workspace ${workspaceId}`); + + // Get the coding job + const codingJob = await this.codingJobRepository.findOne({ + where: { + id, + workspace_id: workspaceId + }, + relations: ['variables', 'variableBundles', 'assignedCoders'] + }); + + if (!codingJob) { + this.logger.warn(`Coding job ${id} not found for workspace ${workspaceId}`); + return undefined; + } + + // Update the coding job properties + if (name !== undefined) { + codingJob.name = name; + } + if (description !== undefined) { + codingJob.description = description; + } + if (status !== undefined) { + codingJob.status = status; + } + + // If variable bundles are specified, update the association + if (variableBundleIds !== undefined) { + const variableBundles = await this.variableBundleRepository.find({ + where: { + id: In(variableBundleIds), + workspaceId + } + }); + codingJob.variables = variableBundles; + } + + // If variable bundle groups are specified, update the association + if (variableBundleGroupIds !== undefined) { + const variableBundleGroups = await this.variableBundleGroupRepository.find({ + where: { + id: In(variableBundleGroupIds), + workspaceId + } + }); + codingJob.variableBundles = variableBundleGroups; + } + + // Save the updated coding job + return this.codingJobRepository.save(codingJob); + } + + /** + * Deletes a coding job + * @param workspaceId The ID of the workspace + * @param id The ID of the coding job + */ + async deleteCodingJob(workspaceId: number, id: number): Promise { + this.logger.log(`Deleting coding job ${id} for workspace ${workspaceId}`); + + const result = await this.codingJobRepository.delete({ + id, + workspace_id: workspaceId + }); + + return result.affected > 0; + } + + /** + * Assigns a coder to a coding job + * @param workspaceId The ID of the workspace + * @param codingJobId The ID of the coding job + * @param coderId The ID of the coder + */ + async assignCoder(workspaceId: number, codingJobId: number, coderId: number): Promise { + this.logger.log(`Assigning coder ${coderId} to coding job ${codingJobId} for workspace ${workspaceId}`); + + // Get the coding job + const codingJob = await this.codingJobRepository.findOne({ + where: { + id: codingJobId, + workspace_id: workspaceId + }, + relations: ['assignedCoders'] + }); + + if (!codingJob) { + this.logger.warn(`Coding job ${codingJobId} not found for workspace ${workspaceId}`); + return undefined; + } + + // Get the coder + const coder = await this.workspaceUserRepository.findOne({ + where: { + userId: coderId, + workspaceId, + accessLevel: 1 // Ensure the user is a coder + } + }); + + if (!coder) { + this.logger.warn(`Coder ${coderId} not found for workspace ${workspaceId}`); + return undefined; + } + + // Check if the coder is already assigned to the coding job + const isAlreadyAssigned = codingJob.assignedCoders.some(c => c.userId === coderId); + if (isAlreadyAssigned) { + this.logger.log(`Coder ${coderId} is already assigned to coding job ${codingJobId}`); + return codingJob; + } + + // Assign the coder to the coding job + codingJob.assignedCoders.push(coder); + + // Save the updated coding job + return this.codingJobRepository.save(codingJob); + } + + /** + * Unassigns a coder from a coding job + * @param workspaceId The ID of the workspace + * @param codingJobId The ID of the coding job + * @param coderId The ID of the coder + */ + async unassignCoder(workspaceId: number, codingJobId: number, coderId: number): Promise { + this.logger.log(`Unassigning coder ${coderId} from coding job ${codingJobId} for workspace ${workspaceId}`); + + // Get the coding job + const codingJob = await this.codingJobRepository.findOne({ + where: { + id: codingJobId, + workspace_id: workspaceId + }, + relations: ['assignedCoders'] + }); + + if (!codingJob) { + this.logger.warn(`Coding job ${codingJobId} not found for workspace ${workspaceId}`); + return undefined; + } + + // Remove the coder from the coding job + codingJob.assignedCoders = codingJob.assignedCoders.filter(c => c.userId !== coderId); + + // Save the updated coding job + return this.codingJobRepository.save(codingJob); + } + + /** + * Gets all coding jobs assigned to a coder + * @param workspaceId The ID of the workspace + * @param coderId The ID of the coder + */ + async getCodingJobsByCoder(workspaceId: number, coderId: number): Promise { + this.logger.log(`Getting coding jobs for coder ${coderId} in workspace ${workspaceId}`); + + const codingJobs = await this.codingJobRepository + .createQueryBuilder('codingJob') + .innerJoin('codingJob.assignedCoders', 'coder') + .where('codingJob.workspace_id = :workspaceId', { workspaceId }) + .andWhere('coder.userId = :coderId', { coderId }) + .getMany(); + + this.logger.log(`Found ${codingJobs.length} coding job(s) for coder ${coderId} in workspace ${workspaceId}`); + return codingJobs; + } +} diff --git a/apps/backend/src/app/database/services/variable-bundle-group.service.ts b/apps/backend/src/app/database/services/variable-bundle-group.service.ts new file mode 100644 index 000000000..8a9b64c77 --- /dev/null +++ b/apps/backend/src/app/database/services/variable-bundle-group.service.ts @@ -0,0 +1,276 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { VariableBundle } from '../entities/variable-bundle.entity'; +import { Variable } from '../entities/variable.entity'; + +@Injectable() +export class VariableBundleGroupService { + private readonly logger = new Logger(VariableBundleGroupService.name); + + constructor( + @InjectRepository(VariableBundle) + private variableBundleRepository: Repository, + @InjectRepository(Variable) + private variableRepository: Repository + ) {} + + /** + * Creates a new variable bundle + * @param workspaceId The ID of the workspace + * @param name The name of the variable bundle + * @param description The description of the variable bundle + * @param variableIds The IDs of the variables to include + */ + async createVariableBundleGroup( + workspaceId: number, + name: string, + description?: string, + variableIds: number[] = [] + ): Promise { + this.logger.log(`Creating variable bundle "${name}" for workspace ${workspaceId}`); + + // Create the variable bundle + const variableBundle = this.variableBundleRepository.create({ + workspaceId, + name, + description + }); + + // Save the variable bundle to get an ID + const savedBundle = await this.variableBundleRepository.save(variableBundle); + + // If variables are specified, associate them with the variable bundle + if (variableIds.length > 0) { + const variables = await this.variableRepository.find({ + where: { + id: In(variableIds), + workspaceId + } + }); + savedBundle.variables = variables; + await this.variableBundleRepository.save(savedBundle); + } + + return savedBundle; + } + + /** + * Gets a variable bundle by ID + * @param workspaceId The ID of the workspace + * @param id The ID of the variable bundle + */ + async getVariableBundleGroup(workspaceId: number, id: number): Promise { + this.logger.log(`Getting variable bundle ${id} for workspace ${workspaceId}`); + + return this.variableBundleRepository.findOne({ + where: { + id, + workspaceId + }, + relations: ['variables', 'codingJobs'] + }); + } + + /** + * Gets all variable bundles for a workspace + * @param workspaceId The ID of the workspace + * @param options Pagination options + */ + async getVariableBundleGroups( + workspaceId: number, + options?: { page: number; limit: number } + ): Promise<[VariableBundle[], number]> { + this.logger.log(`Getting variable bundles for workspace ${workspaceId}`); + + try { + if (options) { + const { page, limit } = options; + const MAX_LIMIT = 500; + const validPage = Math.max(1, page); // minimum 1 + const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT + + const [bundles, total] = await this.variableBundleRepository.findAndCount({ + where: { workspaceId }, + skip: (validPage - 1) * validLimit, + take: validLimit, + order: { id: 'ASC' }, + relations: ['variables', 'codingJobs'] + }); + + this.logger.log(`Found ${bundles.length} variable bundle(s) (page ${validPage}, limit ${validLimit}, total ${total}) for workspace ID: ${workspaceId}`); + return [bundles, total]; + } + + const bundles = await this.variableBundleRepository.find({ + where: { workspaceId }, + order: { id: 'ASC' }, + relations: ['variables', 'codingJobs'] + }); + + this.logger.log(`Found ${bundles.length} variable bundle(s) for workspace ID: ${workspaceId}`); + return [bundles, bundles.length]; + } catch (error) { + this.logger.error(`Failed to retrieve variable bundles for workspace ID: ${workspaceId}`, error.stack); + throw new Error('Could not retrieve variable bundles'); + } + } + + /** + * Updates a variable bundle + * @param workspaceId The ID of the workspace + * @param id The ID of the variable bundle + * @param name The name of the variable bundle + * @param description The description of the variable bundle + * @param variableIds The IDs of the variables to include + */ + async updateVariableBundleGroup( + workspaceId: number, + id: number, + name?: string, + description?: string, + variableIds?: number[] + ): Promise { + this.logger.log(`Updating variable bundle ${id} for workspace ${workspaceId}`); + + // Get the variable bundle + const variableBundle = await this.variableBundleRepository.findOne({ + where: { + id, + workspaceId + }, + relations: ['variables', 'codingJobs'] + }); + + if (!variableBundle) { + this.logger.warn(`Variable bundle ${id} not found for workspace ${workspaceId}`); + return undefined; + } + + // Update the variable bundle properties + if (name !== undefined) { + variableBundle.name = name; + } + if (description !== undefined) { + variableBundle.description = description; + } + + // If variables are specified, update the association + if (variableIds !== undefined) { + const variables = await this.variableRepository.find({ + where: { + id: In(variableIds), + workspaceId + } + }); + variableBundle.variables = variables; + } + + // Save the updated variable bundle + return this.variableBundleRepository.save(variableBundle); + } + + /** + * Deletes a variable bundle + * @param workspaceId The ID of the workspace + * @param id The ID of the variable bundle + */ + async deleteVariableBundleGroup(workspaceId: number, id: number): Promise { + this.logger.log(`Deleting variable bundle ${id} for workspace ${workspaceId}`); + + const result = await this.variableBundleRepository.delete({ + id, + workspaceId + }); + + return result.affected > 0; + } + + /** + * Adds a variable to a variable bundle + * @param workspaceId The ID of the workspace + * @param bundleId The ID of the variable bundle + * @param variableId The ID of the variable + */ + async addVariableBundleToGroup( + workspaceId: number, + bundleId: number, + variableId: number + ): Promise { + this.logger.log(`Adding variable ${variableId} to bundle ${bundleId} for workspace ${workspaceId}`); + + // Get the variable bundle + const variableBundle = await this.variableBundleRepository.findOne({ + where: { + id: bundleId, + workspaceId + }, + relations: ['variables'] + }); + + if (!variableBundle) { + this.logger.warn(`Variable bundle ${bundleId} not found for workspace ${workspaceId}`); + return undefined; + } + + // Get the variable + const variable = await this.variableRepository.findOne({ + where: { + id: variableId, + workspaceId + } + }); + + if (!variable) { + this.logger.warn(`Variable ${variableId} not found for workspace ${workspaceId}`); + return undefined; + } + + // Check if the variable is already in the bundle + const isAlreadyInBundle = variableBundle.variables.some(v => v.id === variableId); + if (isAlreadyInBundle) { + this.logger.log(`Variable ${variableId} is already in bundle ${bundleId}`); + return variableBundle; + } + + // Add the variable to the bundle + variableBundle.variables.push(variable); + + // Save the updated variable bundle + return this.variableBundleRepository.save(variableBundle); + } + + /** + * Removes a variable from a variable bundle + * @param workspaceId The ID of the workspace + * @param bundleId The ID of the variable bundle + * @param variableId The ID of the variable + */ + async removeVariableBundleFromGroup( + workspaceId: number, + bundleId: number, + variableId: number + ): Promise { + this.logger.log(`Removing variable ${variableId} from bundle ${bundleId} for workspace ${workspaceId}`); + + // Get the variable bundle + const variableBundle = await this.variableBundleRepository.findOne({ + where: { + id: bundleId, + workspaceId + }, + relations: ['variables'] + }); + + if (!variableBundle) { + this.logger.warn(`Variable bundle ${bundleId} not found for workspace ${workspaceId}`); + return undefined; + } + + // Remove the variable from the bundle + variableBundle.variables = variableBundle.variables.filter(v => v.id !== variableId); + + // Save the updated variable bundle + return this.variableBundleRepository.save(variableBundle); + } +} diff --git a/apps/backend/src/app/database/services/variable-bundle.service.ts b/apps/backend/src/app/database/services/variable-bundle.service.ts new file mode 100644 index 000000000..c7b4e23f1 --- /dev/null +++ b/apps/backend/src/app/database/services/variable-bundle.service.ts @@ -0,0 +1,194 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Variable } from '../entities/variable.entity'; + +@Injectable() +export class VariableBundleService { + private readonly logger = new Logger(VariableBundleService.name); + + constructor( + @InjectRepository(Variable) + private variableRepository: Repository + ) {} + + /** + * Creates a new variable + * @param workspaceId The ID of the workspace + * @param unitName The name of the unit + * @param variableId The ID of the variable + */ + async createVariableBundle( + workspaceId: number, + unitName: string, + variableId: string + ): Promise { + this.logger.log(`Creating variable for workspace ${workspaceId}, unit ${unitName}, variable ${variableId}`); + + // Check if the variable already exists + const existingVariable = await this.variableRepository.findOne({ + where: { + workspaceId, + unitName, + variableId + } + }); + + if (existingVariable) { + this.logger.log(`Variable already exists for workspace ${workspaceId}, unit ${unitName}, variable ${variableId}`); + return existingVariable; + } + + // Create the variable + const variable = this.variableRepository.create({ + workspaceId, + unitName, + variableId + }); + + // Save the variable + return this.variableRepository.save(variable); + } + + /** + * Gets a variable by ID + * @param workspaceId The ID of the workspace + * @param id The ID of the variable + */ + async getVariableBundle(workspaceId: number, id: number): Promise { + this.logger.log(`Getting variable ${id} for workspace ${workspaceId}`); + + return this.variableRepository.findOne({ + where: { + id, + workspaceId + }, + relations: ['bundles', 'codingJobs'] + }); + } + + /** + * Gets all variables for a workspace + * @param workspaceId The ID of the workspace + * @param options Pagination options + */ + async getVariableBundles( + workspaceId: number, + options?: { page: number; limit: number } + ): Promise<[Variable[], number]> { + this.logger.log(`Getting variables for workspace ${workspaceId}`); + + try { + if (options) { + const { page, limit } = options; + const MAX_LIMIT = 500; + const validPage = Math.max(1, page); // minimum 1 + const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); // Between 1 and MAX_LIMIT + + const [variables, total] = await this.variableRepository.findAndCount({ + where: { workspaceId }, + skip: (validPage - 1) * validLimit, + take: validLimit, + order: { id: 'ASC' }, + relations: ['bundles', 'codingJobs'] + }); + + this.logger.log(`Found ${variables.length} variable(s) (page ${validPage}, limit ${validLimit}, total ${total}) for workspace ID: ${workspaceId}`); + return [variables, total]; + } + + const variables = await this.variableRepository.find({ + where: { workspaceId }, + order: { id: 'ASC' }, + relations: ['bundles', 'codingJobs'] + }); + + this.logger.log(`Found ${variables.length} variable(s) for workspace ID: ${workspaceId}`); + return [variables, variables.length]; + } catch (error) { + this.logger.error(`Failed to retrieve variables for workspace ID: ${workspaceId}`, error.stack); + throw new Error('Could not retrieve variables'); + } + } + + /** + * Gets a variable by unit name and variable ID + * @param workspaceId The ID of the workspace + * @param unitName The name of the unit + * @param variableId The ID of the variable + */ + async getVariableBundleByUnitAndVariable( + workspaceId: number, + unitName: string, + variableId: string + ): Promise { + this.logger.log(`Getting variable for workspace ${workspaceId}, unit ${unitName}, variable ${variableId}`); + + return this.variableRepository.findOne({ + where: { + workspaceId, + unitName, + variableId + }, + relations: ['bundles', 'codingJobs'] + }); + } + + /** + * Gets variables by IDs + * @param workspaceId The ID of the workspace + * @param ids The IDs of the variables + */ + async getVariableBundlesByIds( + workspaceId: number, + ids: number[] + ): Promise { + this.logger.log(`Getting variables with IDs ${ids.join(', ')} for workspace ${workspaceId}`); + + return this.variableRepository.find({ + where: { + id: In(ids), + workspaceId + }, + relations: ['bundles', 'codingJobs'] + }); + } + + /** + * Deletes a variable + * @param workspaceId The ID of the workspace + * @param id The ID of the variable + */ + async deleteVariableBundle(workspaceId: number, id: number): Promise { + this.logger.log(`Deleting variable ${id} for workspace ${workspaceId}`); + + const result = await this.variableRepository.delete({ + id, + workspaceId + }); + + return result.affected > 0; + } + + /** + * Deletes a variable by unit name and variable ID + * @param workspaceId The ID of the workspace + * @param unitName The name of the unit + * @param variableId The ID of the variable + */ + async deleteVariableBundleByUnitAndVariable( + workspaceId: number, + unitName: string, + variableId: string + ): Promise { + this.logger.log(`Deleting variable for workspace ${workspaceId}, unit ${unitName}, variable ${variableId}`); + + const result = await this.variableRepository.delete({ + workspaceId, + unitName, + variableId + }); + + return result.affected > 0; + } +} diff --git a/apps/backend/src/app/database/services/workspace-users.service.ts b/apps/backend/src/app/database/services/workspace-users.service.ts index f658d0625..5a3d471c3 100644 --- a/apps/backend/src/app/database/services/workspace-users.service.ts +++ b/apps/backend/src/app/database/services/workspace-users.service.ts @@ -99,7 +99,7 @@ export class WorkspaceUsersService { this.logger.log(`Retrieving coders (users with accessLevel 1) for workspace ID: ${workspaceId}`); try { - const users = await this.workspaceUsersRepository.find({ + const workspaceUsers = await this.workspaceUsersRepository.find({ where: { workspaceId, accessLevel: 1 @@ -107,8 +107,32 @@ export class WorkspaceUsersService { order: { userId: 'ASC' } }); - this.logger.log(`Found ${users.length} coder(s) for workspace ID: ${workspaceId}`); - return [users, users.length]; + if (workspaceUsers.length === 0) { + this.logger.log(`No coders found for workspace ID: ${workspaceId}`); + return [workspaceUsers, 0]; + } + + const userIds = workspaceUsers.map(wu => wu.userId); + + const users = await this.usersRepository.find({ + where: { id: In(userIds) } + }); + + const userMap = new Map(); + users.forEach(user => { + userMap.set(user.id, user.username); + }); + + const enhancedUsers = workspaceUsers.map(wu => { + const username = userMap.get(wu.userId); + return { + ...wu, + username + }; + }); + + this.logger.log(`Found ${enhancedUsers.length} coder(s) for workspace ID: ${workspaceId}`); + return [enhancedUsers as WorkspaceUser[], enhancedUsers.length]; } catch (error) { this.logger.error(`Failed to retrieve coders for workspace ID: ${workspaceId}`, error.stack); throw new Error('Could not retrieve workspace coders'); diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html new file mode 100644 index 000000000..ba1df824e --- /dev/null +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.html @@ -0,0 +1,284 @@ +
+

{{ data.isEdit ? 'Kodierjob bearbeiten' : 'Neuen Kodierjob erstellen' }}

+ + +
+
+
+

Allgemeine Informationen

+ + + Name + + @if (codingJobForm.get('name')?.invalid && codingJobForm.get('name')?.touched) { + Name ist erforderlich + } + + + + Beschreibung + + + + + Status + + Ausstehend + Aktiv + Abgeschlossen + + +
+ +
+

Variablenbündel

+

Wählen Sie die Variablen und Variablenbündel aus, die diesem Kodierjob zugeordnet werden sollen.

+ + + + + +
+
+ + Aufgaben-ID Filter + + + + + + Variablen-ID Filter + + + + + + + +
+
+ + @if (isLoadingVariableAnalysis) { +
+ +

Lade Variablen...

+
+ } + + @if (!isLoadingVariableAnalysis && variableBundles.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Aufgaben-ID{{ element.unitName }}Variablen-ID{{ element.variableId }}
+ + + +
+ +
+

{{ selectedVariableBundles.selected.length }} Variablen ausgewählt

+
+ } + + @if (!isLoadingVariableAnalysis && variableBundles.length === 0) { +
+ analytics +

Keine Variablen verfügbar

+

Es wurden keine Variablen gefunden. Bitte überprüfen Sie Ihre Filter oder versuchen Sie es später erneut.

+
+ } +
+ + + + +
+
+ + Name Filter + + + + + + + +
+
+ + @if (isLoadingBundleGroups) { +
+ +

Lade Variablenbündel...

+
+ } + + @if (!isLoadingBundleGroups && variableBundleGroups.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name{{ element.name }}Beschreibung{{ element.description }}Anzahl Variablen{{ getVariableCount(element) }}
+
+ +
+

{{ selectedVariableBundleGroups.selected.length }} Variablenbündel ausgewählt

+
+ + + @if (selectedVariableBundleGroups.selected.length > 0) { +
+

Vorschau der ausgewählten Variablenbündel

+ + @for (bundleGroup of selectedVariableBundleGroups.selected; track bundleGroup.id) { + + + + {{ bundleGroup.name }} + + + {{ getVariableCount(bundleGroup) }} Variablen + + + + + + + + + + + + @for (variable of bundleGroup.variables; track variable.unitName + variable.variableId) { + + + + + } + +
Aufgaben-IDVariablen-ID
{{ variable.unitName }}{{ variable.variableId }}
+
+ } +
+
+ } + } + + @if (!isLoadingBundleGroups && variableBundleGroups.length === 0) { +
+ analytics +

Keine Variablenbündel verfügbar

+

Es wurden keine Variablenbündel gefunden. Bitte erstellen Sie zuerst Variablenbündel über die Variablenbündel-Verwaltung.

+
+ } +
+
+
+
+
+ +
+ + +
+
diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss new file mode 100644 index 000000000..edebfeb87 --- /dev/null +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.scss @@ -0,0 +1,178 @@ +.dialog-container { + display: flex; + flex-direction: column; + max-height: 90vh; + min-width: 800px; + padding: 0; +} + +.dialog-title { + font-size: 1.5rem; + font-weight: 500; + margin: 0; + padding: 16px 24px; +} + +.dialog-content { + flex: 1; + overflow-y: auto; + padding: 16px 24px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding: 8px 24px 16px; + gap: 8px; +} + +.form-section { + margin-bottom: 24px; + + h3 { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 16px; + } + + h4 { + font-size: 1.1rem; + font-weight: 500; + margin: 16px 0; + } + + .section-description { + color: rgba(0, 0, 0, 0.6); + margin-bottom: 16px; + } +} + +.full-width { + width: 100%; +} + +.filter-container { + margin: 16px 0; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +} + +.filter-field { + flex: 1; + min-width: 200px; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + + .loading-text { + margin-top: 16px; + color: rgba(0, 0, 0, 0.6); + } +} + +.table-container { + max-height: 300px; + overflow: auto; + margin-bottom: 16px; +} + +.variable-bundles-table { + width: 100%; + + .mat-mdc-row { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + &.selected-row { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .mat-column-select { + width: 60px; + padding-left: 16px; + } +} + +.selection-summary { + margin-top: 16px; + font-weight: 500; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + text-align: center; + + .empty-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: rgba(0, 0, 0, 0.3); + margin-bottom: 16px; + } + + h3 { + margin-bottom: 8px; + } + + .empty-text { + color: rgba(0, 0, 0, 0.6); + max-width: 400px; + } +} + +// Styles for the tabbed interface +::ng-deep .mat-mdc-tab-body-content { + padding: 16px 0; +} + +// Styles for the bundle preview +.bundle-preview-container { + margin-top: 24px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.02); +} + +.preview-table { + width: 100%; + border-collapse: collapse; + margin-top: 8px; + + th, td { + padding: 8px; + text-align: left; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + th { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + } + + tr:last-child td { + border-bottom: none; + } + + tr:hover { + background-color: rgba(0, 0, 0, 0.04); + } +} diff --git a/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts new file mode 100644 index 000000000..faabe38f8 --- /dev/null +++ b/apps/frontend/src/app/coding/components/coding-job-dialog/coding-job-dialog.component.ts @@ -0,0 +1,312 @@ +import { + Component, Inject, OnInit, inject +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators +} from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatTableModule, MatTableDataSource } from '@angular/material/table'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSortModule } from '@angular/material/sort'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { TranslateModule } from '@ngx-translate/core'; +import { SelectionModel } from '@angular/cdk/collections'; +import { CodingJob, VariableBundle, Variable } from '../../models/coding-job.model'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { VariableAnalysisItem } from '../../models/variable-analysis-item.model'; +import { VariableBundleService } from '../../services/variable-bundle.service'; + +export interface CodingJobDialogData { + codingJob?: CodingJob; + isEdit: boolean; +} + +@Component({ + selector: 'coding-box-coding-job-dialog', + templateUrl: './coding-job-dialog.component.html', + styleUrls: ['./coding-job-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + MatChipsModule, + MatTableModule, + MatCheckboxModule, + MatPaginatorModule, + MatSortModule, + MatProgressSpinnerModule, + MatDividerModule, + MatTabsModule, + MatExpansionModule, + TranslateModule + ] +}) +export class CodingJobDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private backendService = inject(BackendService); + private appService = inject(AppService); + private variableBundleGroupService = inject(VariableBundleService); + + codingJobForm!: FormGroup; + isLoading = false; + + // Variables + variableBundles: Variable[] = []; + selectedVariableBundles = new SelectionModel(true, []); + displayedColumns: string[] = ['select', 'unitName', 'variableId']; + dataSource = new MatTableDataSource([]); + + // Variable bundle groups + variableBundleGroups: VariableBundle[] = []; + selectedVariableBundleGroups = new SelectionModel(true, []); + bundleGroupsDisplayedColumns: string[] = ['select', 'name', 'description', 'variableCount']; + bundleGroupsDataSource = new MatTableDataSource([]); + isLoadingBundleGroups = false; + + // Variable analysis items + variableAnalysisItems: VariableAnalysisItem[] = []; + isLoadingVariableAnalysis = false; + totalVariableAnalysisRecords = 0; + variableAnalysisPageIndex = 0; + variableAnalysisPageSize = 10; + variableAnalysisPageSizeOptions = [5, 10, 25, 50]; + + // Filters + unitNameFilter = ''; + variableIdFilter = ''; + bundleGroupNameFilter = ''; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: CodingJobDialogData + ) {} + + ngOnInit(): void { + this.initForm(); + this.loadVariableAnalysisItems(); + this.loadVariableBundleGroups(); + } + + initForm(): void { + this.codingJobForm = this.fb.group({ + name: [this.data.codingJob?.name || '', Validators.required], + description: [this.data.codingJob?.description || ''], + status: [this.data.codingJob?.status || 'pending', Validators.required] + }); + + if (this.data.codingJob?.variables) { + this.variableBundles = [...this.data.codingJob.variables]; + this.dataSource.data = this.variableBundles; + this.selectedVariableBundles = new SelectionModel(true, [...this.variableBundles]); + } + + if (this.data.codingJob?.variableBundles) { + this.selectedVariableBundleGroups = new SelectionModel(true, [...this.data.codingJob.variableBundles]); + } + } + + loadVariableAnalysisItems(page: number = 1, limit: number = 10): void { + this.isLoadingVariableAnalysis = true; + const workspaceId = this.appService.selectedWorkspaceId; + + if (!workspaceId) { + this.isLoadingVariableAnalysis = false; + return; + } + + this.backendService.getVariableAnalysis( + workspaceId, + page, + limit, + this.unitNameFilter || undefined, + this.variableIdFilter || undefined + ).subscribe({ + next: response => { + // Convert variable analysis items to variable bundles + this.variableAnalysisItems = response.data; + + // Create unique variables from the items + const uniqueVariables = new Map(); + + this.variableAnalysisItems.forEach(item => { + const key = `${item.unitId}|${item.variableId}`; + if (!uniqueVariables.has(key)) { + uniqueVariables.set(key, { + unitName: item.unitId, + variableId: item.variableId + }); + } + }); + + this.variableBundles = Array.from(uniqueVariables.values()); + this.dataSource.data = this.variableBundles; + + // Pre-select variables that were already selected + if (this.data.codingJob?.variables) { + this.data.codingJob.variables.forEach(variable => { + const foundVariable = this.variableBundles.find( + b => b.unitName === variable.unitName && b.variableId === variable.variableId + ); + if (foundVariable) { + this.selectedVariableBundles.select(foundVariable); + } + }); + } + + this.totalVariableAnalysisRecords = response.total; + this.variableAnalysisPageIndex = page - 1; + this.isLoadingVariableAnalysis = false; + }, + error: () => { + this.isLoadingVariableAnalysis = false; + } + }); + } + + loadVariableBundleGroups(): void { + this.isLoadingBundleGroups = true; + + this.variableBundleGroupService.getBundleGroups().subscribe({ + next: bundleGroups => { + this.variableBundleGroups = bundleGroups; + this.bundleGroupsDataSource.data = bundleGroups; + + // Pre-select bundle groups that were already selected + if (this.data.codingJob?.variableBundles) { + this.data.codingJob.variableBundles.forEach(group => { + const foundGroup = this.variableBundleGroups.find(g => g.id === group.id); + if (foundGroup) { + this.selectedVariableBundleGroups.select(foundGroup); + } + }); + } + + this.isLoadingBundleGroups = false; + }, + error: () => { + this.isLoadingBundleGroups = false; + } + }); + } + + onPageChange(event: PageEvent): void { + this.loadVariableAnalysisItems(event.pageIndex + 1, event.pageSize); + } + + applyFilter(): void { + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + applyBundleGroupFilter(): void { + if (this.bundleGroupNameFilter) { + this.bundleGroupsDataSource.filter = this.bundleGroupNameFilter.trim().toLowerCase(); + } else { + this.bundleGroupsDataSource.filter = ''; + } + } + + clearFilters(): void { + this.unitNameFilter = ''; + this.variableIdFilter = ''; + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + clearBundleGroupFilter(): void { + this.bundleGroupNameFilter = ''; + this.bundleGroupsDataSource.filter = ''; + } + + /** Whether the number of selected bundle groups matches the total number of rows. */ + isAllBundleGroupsSelected(): boolean { + const numSelected = this.selectedVariableBundleGroups.selected.length; + const numRows = this.bundleGroupsDataSource.data.length; + return numSelected === numRows; + } + + /** Selects all bundle groups if they are not all selected; otherwise clear selection. */ + masterToggleBundleGroups(): void { + if (this.isAllBundleGroupsSelected()) { + this.selectedVariableBundleGroups.clear(); + } else { + this.bundleGroupsDataSource.data.forEach(row => this.selectedVariableBundleGroups.select(row)); + } + } + + /** The label for the checkbox on the passed bundle group row */ + bundleGroupCheckboxLabel(row?: VariableBundle): string { + if (!row) { + return `${this.isAllBundleGroupsSelected() ? 'deselect' : 'select'} all`; + } + return `${this.selectedVariableBundleGroups.isSelected(row) ? 'deselect' : 'select'} row ${row.name}`; + } + + /** Gets the number of variables in a bundle group */ + getVariableCount(bundleGroup: VariableBundle): number { + return bundleGroup.variables.length; + } + + /** Whether the number of selected elements matches the total number of rows. */ + isAllSelected(): boolean { + const numSelected = this.selectedVariableBundles.selected.length; + const numRows = this.dataSource.data.length; + return numSelected === numRows; + } + + /** Selects all rows if they are not all selected; otherwise clear selection. */ + masterToggle(): void { + if (this.isAllSelected()) { + this.selectedVariableBundles.clear(); + } else { + this.dataSource.data.forEach(row => this.selectedVariableBundles.select(row)); + } + } + + /** The label for the checkbox on the passed row */ + checkboxLabel(row?: Variable): string { + if (!row) { + return `${this.isAllSelected() ? 'deselect' : 'select'} all`; + } + return `${this.selectedVariableBundles.isSelected(row) ? 'deselect' : 'select'} row ${row.unitName}`; + } + + onSubmit(): void { + if (this.codingJobForm.invalid) { + return; + } + + const codingJob: CodingJob = { + id: this.data.codingJob?.id || 0, + ...this.codingJobForm.value, + createdAt: this.data.codingJob?.createdAt || new Date(), + updatedAt: new Date(), + assignedCoders: this.data.codingJob?.assignedCoders || [], + variables: this.selectedVariableBundles.selected, + variableBundles: this.selectedVariableBundleGroups.selected + }; + + this.dialogRef.close(codingJob); + } + + onCancel(): void { + this.dialogRef.close(); + } +} diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html index ce925db26..305c5e327 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.html @@ -16,6 +16,10 @@ play_arrow Kodierjob starten + + person_add + Kodierer zuweisen + @if (isLoading) { @@ -68,17 +72,24 @@ - + + Zugewiesene Kodierer + + {{getAssignedCoderNames(element)}} + + + + Erstellt am - {{element.created_at | date: 'dd.MM.yyyy HH:mm'}} + {{element.createdAt | date: 'dd.MM.yyyy HH:mm'}} - + Aktualisiert am - {{element.updated_at | date: 'dd.MM.yyyy HH:mm'}} + {{element.updatedAt | date: 'dd.MM.yyyy HH:mm'}} diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss index 1183a51de..8c56cac58 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.scss @@ -80,6 +80,23 @@ mat-cell { flex: 0 0 120px; } +.mat-column-assignedCoders { + flex: 0 0 180px; +} + +.assigned-coders-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; + cursor: pointer; + + &:hover { + text-decoration: underline; + color: #1976d2; + } +} + .mat-column-created_at, .mat-column-updated_at { flex: 0 0 150px; } diff --git a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts index 563c33879..e71828009 100755 --- a/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts +++ b/apps/frontend/src/app/coding/components/coding-jobs/coding-jobs.component.ts @@ -1,5 +1,5 @@ import { - Component, OnInit, ViewChild, AfterViewInit, inject + Component, OnInit, ViewChild, AfterViewInit, inject, Input } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { MatSort, MatSortModule } from '@angular/material/sort'; @@ -13,16 +13,21 @@ import { MatTableDataSource } from '@angular/material/table'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { SelectionModel } from '@angular/cdk/collections'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatAnchor, MatButton } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { DatePipe, NgClass } from '@angular/common'; import { AppService } from '../../../services/app.service'; import { BackendService } from '../../../services/backend.service'; import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; import { CodingJob } from '../../models/coding-job.model'; +import { CodingJobDialogComponent } from '../coding-job-dialog/coding-job-dialog.component'; +import { Coder } from '../../models/coder.model'; +import { CoderService } from '../../services/coder.service'; @Component({ selector: 'coding-box-coding-jobs', @@ -49,15 +54,24 @@ import { CodingJob } from '../../models/coding-job.model'; MatRowDef, MatColumnDef, MatSortModule, - MatButton + MatButton, + MatDialogModule, + MatTooltipModule ] }) export class CodingJobsComponent implements OnInit, AfterViewInit { appService = inject(AppService); backendService = inject(BackendService); private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + private coderService = inject(CoderService); - displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'createdAt', 'updatedAt']; + // Cache for storing coder names by job ID + private coderNamesByJobId = new Map(); + + @Input() selectedCoder: Coder | null = null; + + displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'status', 'assignedCoders', 'createdAt', 'updatedAt']; dataSource = new MatTableDataSource([]); selection = new SelectionModel(true, []); isLoading = false; @@ -139,16 +153,66 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { } createCodingJob(): void { - this.snackBar.open('Funktion zum Erstellen eines Kodierjobs noch nicht implementiert', 'Schließen', { duration: 3000 }); + const dialogRef = this.dialog.open(CodingJobDialogComponent, { + width: '900px', + data: { + isEdit: false + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const newId = this.getNextId(); + const newCodingJob: CodingJob = { + ...result, + id: newId + }; + const currentData = this.dataSource.data; + this.dataSource.data = [...currentData, newCodingJob]; + this.snackBar.open(`Kodierjob "${newCodingJob.name}" wurde erstellt`, 'Schließen', { duration: 3000 }); + } + }); } editCodingJob(): void { if (this.selection.selected.length === 1) { const selectedJob = this.selection.selected[0]; - this.snackBar.open(`Bearbeiten von Kodierjob "${selectedJob.name}" noch nicht implementiert`, 'Schließen', { duration: 3000 }); + + const dialogRef = this.dialog.open(CodingJobDialogComponent, { + width: '900px', + data: { + codingJob: selectedJob, + isEdit: true + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const currentData = this.dataSource.data; + const index = currentData.findIndex(job => job.id === result.id); + + if (index !== -1) { + const updatedData = [...currentData]; + updatedData[index] = result; + this.dataSource.data = updatedData; + + this.snackBar.open(`Kodierjob "${result.name}" wurde aktualisiert`, 'Schließen', { duration: 3000 }); + } + } + }); } } + /** + * Gets the next available ID for a new coding job + */ + private getNextId(): number { + const jobs = this.dataSource.data; + return jobs.length > 0 ? + Math.max(...jobs.map(job => job.id)) + 1 : + 1; + } + deleteCodingJobs(): void { if (this.selection.selected.length > 0) { const count = this.selection.selected.length; @@ -188,4 +252,132 @@ export class CodingJobsComponent implements OnInit, AfterViewInit { return status; } } + + /** + * Assigns the selected coding jobs to the selected coder + */ + assignToCoder(): void { + if (!this.selectedCoder) { + this.snackBar.open('Bitte wählen Sie zuerst einen Kodierer aus', 'Schließen', { duration: 3000 }); + return; + } + + if (this.selection.selected.length === 0) { + this.snackBar.open('Bitte wählen Sie mindestens einen Kodierjob aus', 'Schließen', { duration: 3000 }); + return; + } + + const coderId = this.selectedCoder.id; + const selectedJobs = this.selection.selected; + let assignedCount = 0; + + // Assign each selected job to the coder + selectedJobs.forEach(job => { + this.coderService.assignJob(coderId, job.id).subscribe({ + next: updatedCoder => { + if (updatedCoder) { + assignedCount += 1; + + // Update the job in the data source to reflect the assignment + const jobIndex = this.dataSource.data.findIndex(j => j.id === job.id); + if (jobIndex !== -1) { + const updatedJob = { ...this.dataSource.data[jobIndex] }; + + // Add the coder to the job's assignedCoders array if not already there + if (!updatedJob.assignedCoders.includes(coderId)) { + updatedJob.assignedCoders = [...updatedJob.assignedCoders, coderId]; + + // Update the data source + const updatedData = [...this.dataSource.data]; + updatedData[jobIndex] = updatedJob; + this.dataSource.data = updatedData; + } + } + + // Show success message when all jobs have been processed + if (assignedCount === selectedJobs.length) { + const jobText = selectedJobs.length === 1 ? 'Kodierjob' : 'Kodierjobs'; + this.snackBar.open( + `${selectedJobs.length} ${jobText} wurde(n) ${this.selectedCoder!.displayName} zugewiesen`, + 'Schließen', + { duration: 3000 } + ); + } + } + }, + error: () => { + this.snackBar.open( + `Fehler beim Zuweisen des Kodierjobs an ${this.selectedCoder!.displayName}`, + 'Schließen', + { duration: 3000 } + ); + } + }); + }); + } + + /** + * Gets the names of coders assigned to a job (truncated if too many) + * @param job The coding job + */ + getAssignedCoderNames(job: CodingJob): string { + if (!job.assignedCoders || job.assignedCoders.length === 0) { + return 'Keine'; + } + + // Store coder names for this job if we've already fetched them + if (!this.coderNamesByJobId.has(job.id)) { + // Fetch coders assigned to this job + this.coderService.getCodersByJobId(job.id).subscribe({ + next: coders => { + if (coders.length > 0) { + // Store the formatted names for this job + const coderNames = coders.map(coder => coder.displayName || coder.name).join(', '); + this.coderNamesByJobId.set(job.id, coderNames); + + // Refresh the data source to trigger UI update + const currentData = [...this.dataSource.data]; + this.dataSource.data = currentData; + } else { + this.coderNamesByJobId.set(job.id, 'Keine'); + } + }, + error: () => { + this.coderNamesByJobId.set(job.id, `${job.assignedCoders.length} Kodierer`); + } + }); + + // Return a loading indicator while we fetch the names + return 'Lade Kodierer...'; + } + + // Get the cached coder names for this job + const coderNames = this.coderNamesByJobId.get(job.id) || `${job.assignedCoders.length} Kodierer`; + + // Truncate the list if it's too long (more than 2 coders) + if (coderNames !== 'Keine' && coderNames !== 'Lade Kodierer...' && job.assignedCoders.length > 2) { + const namesList = coderNames.split(', '); + return `${namesList[0]}, ${namesList[1]} +${job.assignedCoders.length - 2} weitere`; + } + + return coderNames; + } + + /** + * Gets the full list of coder names for the tooltip + * @param job The coding job + */ + getFullCoderNames(job: CodingJob): string { + if (!job.assignedCoders || job.assignedCoders.length === 0) { + return 'Keine Kodierer zugewiesen'; + } + + // If we haven't fetched the names yet, show a loading message + if (!this.coderNamesByJobId.has(job.id)) { + return 'Lade Kodierer...'; + } + + // Return the full list of coder names + return this.coderNamesByJobId.get(job.id) || `${job.assignedCoders.length} Kodierer`; + } } diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html index 49df0c89f..d49fe99ed 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html @@ -18,6 +18,16 @@

Manuelle Kodierung planen

Verwalten Sie Kodierer und Kodierjobs für die manuelle Kodierung von Antworten.


+
+ + Kodierer auswählen + + + {{ coder.displayName || coder.name }} + + + +

Kodierer

@@ -25,7 +35,11 @@

Kodierer

Kodierjobs

- + +
+
+

Variablenbündel

+
diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss index 80c25f552..6479b3c33 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss @@ -93,13 +93,43 @@ border-top: 1px solid rgba(0, 0, 0, 0.1); } + // Coder selection styles + .coder-selection-container { + margin-bottom: 20px; + + .coder-select { + width: 100%; + max-width: 400px; + } + + ::ng-deep .mat-mdc-form-field { + width: 100%; + max-width: 400px; + + .mat-mdc-select-value { + font-size: 14px; + } + + .mat-mdc-form-field-infix { + width: auto; + min-width: 200px; + } + } + } + .statistics-content { margin: 20px 0; - .coder-list-container, .coding-jobs-container { + .coder-list-container, .coding-jobs-container, .variable-bundle-groups-container { margin-bottom: 20px; } + .variable-bundle-groups-container { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid rgba(0, 0, 0, 0.1); + } + h3 { font-size: 18px; font-weight: 500; diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts index 558f24256..c1f263029 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.ts @@ -1,18 +1,58 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; +import { NgFor } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { MatAnchor, MatButton } from '@angular/material/button'; -import { - MatCard, MatCardContent, MatCardHeader, MatCardTitle -} from '@angular/material/card'; import { MatIcon } from '@angular/material/icon'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatSelect, MatOption, MatSelectChange } from '@angular/material/select'; import { CoderListComponent } from '../coder-list/coder-list.component'; import { CodingJobsComponent } from '../coding-jobs/coding-jobs.component'; +import { VariableBundleManagerComponent } from '../variable-bundle-manager/variable-bundle-manager.component'; +import { CoderService } from '../../services/coder.service'; +import { Coder } from '../../models/coder.model'; @Component({ selector: 'coding-box-coding-management-manual', templateUrl: './coding-management-manual.component.html', styleUrls: ['./coding-management-manual.component.scss'], - imports: [TranslateModule, CoderListComponent, MatAnchor, CodingJobsComponent, MatCardContent, MatCardTitle, MatCardHeader, MatCard, MatIcon, MatButton] + imports: [ + NgFor, + TranslateModule, + CoderListComponent, + MatAnchor, + CodingJobsComponent, + MatIcon, + MatButton, + MatFormField, + MatLabel, + MatSelect, + MatOption, + VariableBundleManagerComponent + ] }) -export class CodingManagementManualComponent { +export class CodingManagementManualComponent implements OnInit { + private coderService = inject(CoderService); + + coders: Coder[] = []; + selectedCoder: Coder | null = null; + + ngOnInit(): void { + this.loadCoders(); + } + + loadCoders(): void { + this.coderService.getCoders().subscribe({ + next: coders => { + this.coders = coders; + }, + error: error => { + console.error('Error loading coders:', error); + } + }); + } + + onCoderSelected(event: MatSelectChange): void { + const coderId = event.value; + this.selectedCoder = this.coders.find(coder => coder.id === coderId) || null; + } } diff --git a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts index 6e2513897..9f857539a 100644 --- a/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts +++ b/apps/frontend/src/app/coding/components/my-coding-jobs/my-coding-jobs.component.ts @@ -24,6 +24,7 @@ import { BackendService } from '../../../services/backend.service'; import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; import { CodingJob } from '../../models/coding-job.model'; import { WorkspaceUserDto } from '../../../../../../../api-dto/workspaces/workspace-user-dto'; +import { CoderService } from '../../services/coder.service'; @Component({ selector: 'coding-box-my-coding-jobs', @@ -57,6 +58,7 @@ export class MyCodingJobsComponent implements OnInit, AfterViewInit { backendService = inject(BackendService); private snackBar = inject(MatSnackBar); private router = inject(Router); + private coderService = inject(CoderService); displayedColumns: string[] = ['name', 'description', 'status', 'createdAt', 'updatedAt']; dataSource = new MatTableDataSource([]); @@ -103,41 +105,54 @@ export class MyCodingJobsComponent implements OnInit, AfterViewInit { loadMyCodingJobs(): void { this.isLoading = true; - setTimeout(() => { - const allJobs = [ - { - id: 1, - name: 'Kodierjob 1', - description: 'Beschreibung für Kodierjob 1', - status: 'active', - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-15'), - assignedCoders: [1, 2] - }, - { - id: 2, - name: 'Kodierjob 2', - description: 'Beschreibung für Kodierjob 2', - status: 'completed', - createdAt: new Date('2023-02-01'), - updatedAt: new Date('2023-02-15'), - assignedCoders: [3] - }, - { - id: 3, - name: 'Kodierjob 3', - description: 'Beschreibung für Kodierjob 3', - status: 'pending', - createdAt: new Date('2023-03-01'), - updatedAt: new Date('2023-03-15'), - assignedCoders: [1] - } - ]; + const sampleJobs = [ + { + id: 1, + name: 'Kodierjob 1', + description: 'Beschreibung für Kodierjob 1', + status: 'active', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-15'), + assignedCoders: [1, 2] + }, + { + id: 2, + name: 'Kodierjob 2', + description: 'Beschreibung für Kodierjob 2', + status: 'completed', + createdAt: new Date('2023-02-01'), + updatedAt: new Date('2023-02-15'), + assignedCoders: [3] + }, + { + id: 3, + name: 'Kodierjob 3', + description: 'Beschreibung für Kodierjob 3', + status: 'pending', + createdAt: new Date('2023-03-01'), + updatedAt: new Date('2023-03-15'), + assignedCoders: [1] + } + ]; + + this.coderService.getCodersByJobId(this.currentUserId).subscribe({ + next: coders => { + if (coders.length > 0) { + const currentCoder = coders[0]; + const assignedJobIds = currentCoder.assignedJobs || []; - this.dataSource.data = allJobs.filter(job => job.assignedCoders.includes(this.currentUserId)); + this.dataSource.data = sampleJobs.filter(job => assignedJobIds.includes(job.id)); + } else { + this.dataSource.data = []; + } - this.isLoading = false; - }, 500); + this.isLoading = false; + }, + error: () => { + this.snackBar.open('Fehler beim Laden der Kodierjobs', 'Schließen', { duration: 3000 }); + this.isLoading = false; + } + }); } applyFilter(filterValue: string): void { diff --git a/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html new file mode 100644 index 000000000..1be69c0c2 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.html @@ -0,0 +1,183 @@ +
+

{{ data.isEdit ? 'Variablenbündel bearbeiten' : 'Neues Variablenbündel erstellen' }}

+ + +
+
+
+

Allgemeine Informationen

+ + + Name + + @if (bundleGroupForm.get('name')?.invalid && bundleGroupForm.get('name')?.touched) { + Name ist erforderlich + } + + + + Beschreibung + + +
+ +
+

Variablen im Bündel

+

Aktuell ausgewählte Variablen in diesem Bündel:

+ +
+ @if (selectedVariablesDataSource.data.length === 0) { +
+

Keine Variablen ausgewählt. Wählen Sie unten Variablen aus, um sie diesem Bündel hinzuzufügen.

+
+ } + @if (selectedVariablesDataSource.data.length > 0) { + + + + + + + + + + + + + + + + + + + + + +
Aufgaben-ID{{ element.unitName }}Variablen-ID{{ element.variableId }}Aktionen + +
+ } +
+
+ +
+

Verfügbare Variablen

+

Wählen Sie Variablen aus, um sie dem Bündel hinzuzufügen:

+ + +
+
+ + Aufgaben-ID Filter + + + + + + Variablen-ID Filter + + + + + + + +
+
+ + @if (isLoadingVariableAnalysis) { +
+ +

Lade Variablen...

+
+ } + + @if (!isLoadingVariableAnalysis && availableVariables.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Aufgaben-ID{{ element.unitName }}Variablen-ID{{ element.variableId }}
+ + + +
+ +
+ +
+ } + + @if (!isLoadingVariableAnalysis && availableVariables.length === 0) { +
+ analytics +

Keine Variablen verfügbar

+

Es wurden keine Variablen gefunden. Bitte überprüfen Sie Ihre Filter oder versuchen Sie es später erneut.

+
+ } +
+
+
+ +
+ + +
+
diff --git a/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.scss b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.scss new file mode 100644 index 000000000..f4a457e41 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.scss @@ -0,0 +1,147 @@ +.dialog-container { + display: flex; + flex-direction: column; + max-height: 90vh; + min-width: 800px; + padding: 0; +} + +.dialog-title { + font-size: 1.5rem; + font-weight: 500; + margin: 0; + padding: 16px 24px; +} + +.dialog-content { + flex: 1; + overflow-y: auto; + padding: 16px 24px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding: 8px 24px 16px; + gap: 8px; +} + +.form-section { + margin-bottom: 24px; + + h3 { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 16px; + } + + .section-description { + color: rgba(0, 0, 0, 0.6); + margin-bottom: 16px; + } +} + +.full-width { + width: 100%; +} + +.filter-container { + margin-bottom: 16px; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +} + +.filter-field { + flex: 1; + min-width: 200px; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + + .loading-text { + margin-top: 16px; + color: rgba(0, 0, 0, 0.6); + } +} + +.table-container { + max-height: 300px; + overflow: auto; + margin-bottom: 16px; +} + +.variable-bundles-table { + width: 100%; + + .mat-mdc-row { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + &.selected-row { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .mat-column-select { + width: 60px; + padding-left: 16px; + } +} + +.selected-variables-container { + margin-bottom: 24px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.02); +} + +.selected-variables-table { + width: 100%; +} + +.action-buttons { + display: flex; + justify-content: flex-start; + margin-top: 16px; + gap: 8px; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 0; + text-align: center; + + .empty-icon { + font-size: 48px; + height: 48px; + width: 48px; + color: rgba(0, 0, 0, 0.3); + margin-bottom: 16px; + } + + h3 { + margin-bottom: 8px; + } + + .empty-text { + color: rgba(0, 0, 0, 0.6); + max-width: 400px; + } +} diff --git a/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.ts b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.ts new file mode 100644 index 000000000..d0d48f084 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-dialog/variable-bundle-dialog.component.ts @@ -0,0 +1,246 @@ +import { + Component, Inject, OnInit, inject +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators +} from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatTableModule, MatTableDataSource } from '@angular/material/table'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSortModule } from '@angular/material/sort'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { TranslateModule } from '@ngx-translate/core'; +import { SelectionModel } from '@angular/cdk/collections'; +import { VariableBundle, Variable } from '../../models/coding-job.model'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { VariableAnalysisItem } from '../../models/variable-analysis-item.model'; + +export interface VariableBundleGroupDialogData { + bundleGroup?: VariableBundle; + isEdit: boolean; +} + +@Component({ + selector: 'coding-box-variable-bundle-group-dialog', + templateUrl: './variable-bundle-dialog.component.html', + styleUrls: ['./variable-bundle-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + MatChipsModule, + MatTableModule, + MatCheckboxModule, + MatPaginatorModule, + MatSortModule, + MatProgressSpinnerModule, + MatDividerModule, + TranslateModule + ] +}) +export class VariableBundleDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private backendService = inject(BackendService); + private appService = inject(AppService); + + bundleGroupForm!: FormGroup; + isLoading = false; + + // Variables + availableVariables: Variable[] = []; + selectedVariables = new SelectionModel(true, []); + displayedColumns: string[] = ['select', 'unitName', 'variableId']; + dataSource = new MatTableDataSource([]); + + // Variable analysis items + variableAnalysisItems: VariableAnalysisItem[] = []; + isLoadingVariableAnalysis = false; + totalVariableAnalysisRecords = 0; + variableAnalysisPageIndex = 0; + variableAnalysisPageSize = 10; + variableAnalysisPageSizeOptions = [5, 10, 25, 50]; + + // Filters + unitNameFilter = ''; + variableIdFilter = ''; + + // Selected variables table + selectedVariablesDataSource = new MatTableDataSource([]); + selectedVariablesDisplayedColumns: string[] = ['unitName', 'variableId', 'actions']; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: VariableBundleGroupDialogData + ) {} + + ngOnInit(): void { + this.initForm(); + this.loadVariableAnalysisItems(); + + if (this.data.bundleGroup?.variables) { + this.selectedVariablesDataSource.data = [...this.data.bundleGroup.variables]; + } + } + + initForm(): void { + this.bundleGroupForm = this.fb.group({ + name: [this.data.bundleGroup?.name || '', Validators.required], + description: [this.data.bundleGroup?.description || ''] + }); + } + + loadVariableAnalysisItems(page: number = 1, limit: number = 10): void { + this.isLoadingVariableAnalysis = true; + const workspaceId = this.appService.selectedWorkspaceId; + + if (!workspaceId) { + this.isLoadingVariableAnalysis = false; + return; + } + + this.backendService.getVariableAnalysis( + workspaceId, + page, + limit, + this.unitNameFilter || undefined, + this.variableIdFilter || undefined + ).subscribe({ + next: response => { + // Convert variable analysis items to variable bundles + this.variableAnalysisItems = response.data; + + // Create unique variables from the items + const uniqueVariables = new Map(); + + this.variableAnalysisItems.forEach(item => { + const key = `${item.unitId}|${item.variableId}`; + if (!uniqueVariables.has(key)) { + uniqueVariables.set(key, { + unitName: item.unitId, + variableId: item.variableId + }); + } + }); + + this.availableVariables = Array.from(uniqueVariables.values()); + this.dataSource.data = this.availableVariables; + + // Pre-select variables that are already in the bundle group + if (this.data.bundleGroup?.variables) { + this.data.bundleGroup.variables.forEach((variable: Variable) => { + const foundVariable = this.availableVariables.find( + v => v.unitName === variable.unitName && v.variableId === variable.variableId + ); + if (foundVariable) { + this.selectedVariables.select(foundVariable); + } + }); + } + + this.totalVariableAnalysisRecords = response.total; + this.variableAnalysisPageIndex = page - 1; + this.isLoadingVariableAnalysis = false; + }, + error: () => { + this.isLoadingVariableAnalysis = false; + } + }); + } + + onPageChange(event: PageEvent): void { + this.loadVariableAnalysisItems(event.pageIndex + 1, event.pageSize); + } + + applyFilter(): void { + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + clearFilters(): void { + this.unitNameFilter = ''; + this.variableIdFilter = ''; + this.loadVariableAnalysisItems(1, this.variableAnalysisPageSize); + } + + /** Whether the number of selected elements matches the total number of rows. */ + isAllSelected(): boolean { + const numSelected = this.selectedVariables.selected.length; + const numRows = this.dataSource.data.length; + return numSelected === numRows; + } + + /** Selects all rows if they are not all selected; otherwise clear selection. */ + masterToggle(): void { + if (this.isAllSelected()) { + this.selectedVariables.clear(); + } else { + this.dataSource.data.forEach(row => this.selectedVariables.select(row)); + } + } + + /** The label for the checkbox on the passed row */ + checkboxLabel(row?: Variable): string { + if (!row) { + return `${this.isAllSelected() ? 'deselect' : 'select'} all`; + } + return `${this.selectedVariables.isSelected(row) ? 'deselect' : 'select'} row ${row.unitName}`; + } + + /** Add selected variables to the bundle group */ + addSelectedVariables(): void { + const currentVariables = this.selectedVariablesDataSource.data; + const newVariables = this.selectedVariables.selected.filter(variable => !currentVariables.some(v => v.unitName === variable.unitName && v.variableId === variable.variableId + ) + ); + + if (newVariables.length > 0) { + this.selectedVariablesDataSource.data = [...currentVariables, ...newVariables]; + this.selectedVariables.clear(); + } + } + + /** Remove a variable from the bundle group */ + removeVariable(variable: Variable): void { + const currentVariables = this.selectedVariablesDataSource.data; + const updatedVariables = currentVariables.filter(v => !(v.unitName === variable.unitName && v.variableId === variable.variableId) + ); + + this.selectedVariablesDataSource.data = updatedVariables; + } + + onSubmit(): void { + if (this.bundleGroupForm.invalid) { + return; + } + + const bundleGroup: VariableBundle = { + id: this.data.bundleGroup?.id || 0, + ...this.bundleGroupForm.value, + createdAt: this.data.bundleGroup?.createdAt || new Date(), + updatedAt: new Date(), + variables: this.selectedVariablesDataSource.data + }; + + this.dialogRef.close(bundleGroup); + } + + onCancel(): void { + this.dialogRef.close(); + } +} diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html new file mode 100644 index 000000000..a3696c676 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html @@ -0,0 +1,103 @@ +
+ + + @if (isLoading) { +
+ +
+ } + @if (!isLoading) { + + + + + + + + + + + + + + + + + + + + + Name + + {{element.name}} + + + + + Beschreibung + + {{element.description}} + + + + + Anzahl Variablen + + {{getVariableCount(element)}} + + + + + Erstellt am + + {{element.createdAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + Aktualisiert am + + {{element.updatedAt | date: 'dd.MM.yyyy HH:mm'}} + + + + + Aktionen + + + + + + + + @if (dataSource.data.length === 0) { +
+

Keine Variablenbündel vorhanden. Erstellen Sie ein neues Variablenbündel mit dem Button oben.

+
+ } + } +
diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.scss b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.scss new file mode 100644 index 000000000..31f013831 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.scss @@ -0,0 +1,79 @@ +.container { + width: 100%; + padding: 16px; +} + +.action-buttons { + margin-bottom: 16px; +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 200px; +} + +.variable-bundle-groups-table { + width: 100%; + margin-top: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; + + .mat-mdc-row { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + &.selected { + background-color: rgba(0, 0, 0, 0.08); + } + } + + .mat-column-selectCheckbox { + flex: 0 0 60px; + } + + .mat-column-name { + flex: 1; + min-width: 150px; + } + + .mat-column-description { + flex: 2; + min-width: 200px; + } + + .mat-column-variableCount { + flex: 0 0 120px; + justify-content: center; + } + + .mat-column-createdAt, + .mat-column-updatedAt { + flex: 0 0 160px; + } + + .mat-column-actions { + flex: 0 0 100px; + justify-content: center; + } +} + +.no-data-message { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100px; + color: rgba(0, 0, 0, 0.6); + font-style: italic; + text-align: center; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 4px; + margin-top: 16px; +} diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts new file mode 100644 index 000000000..850b29e65 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts @@ -0,0 +1,212 @@ +import { + Component, OnInit, ViewChild, AfterViewInit, inject +} from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { + MatCell, MatCellDef, MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, MatHeaderRowDef, + MatRow, MatRowDef, + MatTable, + MatTableDataSource, + MatTableModule +} from '@angular/material/table'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { SelectionModel } from '@angular/cdk/collections'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatAnchor, MatButton } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; +import { VariableBundle } from '../../models/coding-job.model'; +import { VariableBundleService } from '../../services/variable-bundle.service'; +import { VariableBundleDialogComponent } from '../variable-bundle-dialog/variable-bundle-dialog.component'; + +@Component({ + selector: 'coding-box-variable-bundle-manager', + templateUrl: './variable-bundle-manager.component.html', + styleUrls: ['./variable-bundle-manager.component.scss'], + standalone: true, + imports: [ + CommonModule, + TranslateModule, + DatePipe, + SearchFilterComponent, + MatIcon, + MatHeaderCell, + MatCell, + MatHeaderRow, + MatRow, + MatProgressSpinner, + MatCheckbox, + MatTable, + MatTableModule, + MatAnchor, + MatHeaderCellDef, + MatCellDef, + MatHeaderRowDef, + MatRowDef, + MatColumnDef, + MatSortModule, + MatButton, + MatDialogModule, + MatTooltipModule + ] +}) +export class VariableBundleManagerComponent implements OnInit, AfterViewInit { + private variableBundleGroupService = inject(VariableBundleService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + displayedColumns: string[] = ['selectCheckbox', 'name', 'description', 'variableCount', 'createdAt', 'updatedAt', 'actions']; + dataSource = new MatTableDataSource([]); + selection = new SelectionModel(true, []); + isLoading = false; + + @ViewChild(MatSort) sort!: MatSort; + + ngOnInit(): void { + this.loadVariableBundleGroups(); + } + + ngAfterViewInit(): void { + this.dataSource.sort = this.sort; + } + + loadVariableBundleGroups(): void { + this.isLoading = true; + + this.variableBundleGroupService.getBundleGroups().subscribe({ + next: bundleGroups => { + this.dataSource.data = bundleGroups; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + this.snackBar.open('Fehler beim Laden der Variablenbündel', 'Schließen', { duration: 3000 }); + } + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + isAllSelected(): boolean { + const numSelected = this.selection.selected.length; + const numRows = this.dataSource.data.length; + return numSelected === numRows; + } + + isIndeterminate(): boolean { + return this.selection.selected.length > 0 && !this.isAllSelected(); + } + + masterToggle(): void { + if (this.isAllSelected()) { + this.selection.clear(); + } else { + this.dataSource.data.forEach(row => this.selection.select(row)); + } + } + + selectRow(row: VariableBundle): void { + this.selection.toggle(row); + } + + createVariableBundleGroup(): void { + const dialogRef = this.dialog.open(VariableBundleDialogComponent, { + width: '900px', + data: { + isEdit: false + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.variableBundleGroupService.createBundleGroup(result).subscribe({ + next: newBundleGroup => { + this.loadVariableBundleGroups(); + this.snackBar.open(`Variablenbündel "${newBundleGroup.name}" wurde erstellt`, 'Schließen', { duration: 3000 }); + }, + error: () => { + this.snackBar.open('Fehler beim Erstellen des Variablenbündels', 'Schließen', { duration: 3000 }); + } + }); + } + }); + } + + editVariableBundleGroup(bundleGroup: VariableBundle): void { + const dialogRef = this.dialog.open(VariableBundleDialogComponent, { + width: '900px', + data: { + bundleGroup, + isEdit: true + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.variableBundleGroupService.updateBundleGroup(bundleGroup.id, result).subscribe({ + next: updatedBundleGroup => { + if (updatedBundleGroup) { + this.loadVariableBundleGroups(); + this.snackBar.open(`Variablenbündel "${updatedBundleGroup.name}" wurde aktualisiert`, 'Schließen', { duration: 3000 }); + } + }, + error: () => { + this.snackBar.open('Fehler beim Aktualisieren des Variablenbündels', 'Schließen', { duration: 3000 }); + } + }); + } + }); + } + + deleteVariableBundleGroup(bundleGroup: VariableBundle): void { + if (confirm(`Sind Sie sicher, dass Sie das Variablenbündel "${bundleGroup.name}" löschen möchten?`)) { + this.variableBundleGroupService.deleteBundleGroup(bundleGroup.id).subscribe({ + next: success => { + if (success) { + this.loadVariableBundleGroups(); + this.snackBar.open(`Variablenbündel "${bundleGroup.name}" wurde gelöscht`, 'Schließen', { duration: 3000 }); + } + }, + error: error => { + console.error('Error deleting variable bundle group:', error); + this.snackBar.open('Fehler beim Löschen des Variablenbündels', 'Schließen', { duration: 3000 }); + } + }); + } + } + + deleteSelectedVariableBundleGroups(): void { + if (this.selection.selected.length === 0) { + return; + } + + if (confirm(`Sind Sie sicher, dass Sie ${this.selection.selected.length} ausgewählte Variablenbündel löschen möchten?`)) { + const deletePromises = this.selection.selected.map(bundleGroup => this.variableBundleGroupService.deleteBundleGroup(bundleGroup.id) + ); + + // Wait for all delete operations to complete + Promise.all(deletePromises).then(() => { + this.loadVariableBundleGroups(); + this.selection.clear(); + this.snackBar.open(`${this.selection.selected.length} Variablenbündel wurden gelöscht`, 'Schließen', { duration: 3000 }); + }).catch(error => { + console.error('Error deleting variable bundle groups:', error); + this.snackBar.open('Fehler beim Löschen der Variablenbündel', 'Schließen', { duration: 3000 }); + }); + } + } + + getVariableCount(bundleGroup: VariableBundle): number { + return bundleGroup.variables.length; + } +} diff --git a/apps/frontend/src/app/coding/models/coding-job.model.ts b/apps/frontend/src/app/coding/models/coding-job.model.ts index d53264549..1b38bc442 100644 --- a/apps/frontend/src/app/coding/models/coding-job.model.ts +++ b/apps/frontend/src/app/coding/models/coding-job.model.ts @@ -6,4 +6,20 @@ export interface CodingJob { createdAt: Date; updatedAt: Date; assignedCoders: number[]; + variables?: Variable[]; + variableBundles?: VariableBundle[]; +} + +export interface Variable { + unitName: string; + variableId: string; +} + +export interface VariableBundle { + id: number; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + variables: Variable[]; } diff --git a/apps/frontend/src/app/coding/services/coder.service.ts b/apps/frontend/src/app/coding/services/coder.service.ts index 4f861b217..6a8d09a2b 100644 --- a/apps/frontend/src/app/coding/services/coder.service.ts +++ b/apps/frontend/src/app/coding/services/coder.service.ts @@ -1,8 +1,10 @@ import { Injectable, inject } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; +import { catchError, map } from 'rxjs/operators'; import { Coder } from '../models/coder.model'; import { SERVER_URL } from '../../injection-tokens'; +import { AppService } from '../../services/app.service'; @Injectable({ providedIn: 'root' @@ -10,29 +12,23 @@ import { SERVER_URL } from '../../injection-tokens'; export class CoderService { private http = inject(HttpClient); private readonly serverUrl = inject(SERVER_URL); - - // Initialize with empty array + private appService = inject(AppService); private codersSubject = new BehaviorSubject([]); - /** - * Gets all coders (users with accessLevel 1) for the current workspace - */ getCoders(): Observable { - // Get the current workspace ID from localStorage - const workspaceId = localStorage.getItem('workspace_id'); - + const workspaceId = this.appService.selectedWorkspaceId; if (!workspaceId) { - console.error('No workspace ID found in localStorage'); + console.error('No workspace ID available'); return of([]); } - - // Fetch coders from the API - const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coders`; + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coders`; interface WorkspaceUser { userId: number; workspaceId: number; accessLevel: number; + username: string; } this.http.get<{ data: WorkspaceUser[], total: number }>(url).subscribe({ @@ -40,8 +36,8 @@ export class CoderService { // Map the workspace users with accessLevel 1 to Coder objects const coders: Coder[] = response.data.map(user => ({ id: user.userId, - name: `User ${user.userId}`, // Default name if user details not available - displayName: `Coder ${user.userId}`, // Default display name + name: user.username || `User ${user.userId}`, // Use username if available, otherwise fallback to default + displayName: user.username || `Coder ${user.userId}`, // Use username if available, otherwise fallback to default assignedJobs: [] })); @@ -58,19 +54,6 @@ export class CoderService { return this.codersSubject.asObservable(); } - /** - * Gets a coder by ID - * @param id The ID of the coder to get - */ - getCoderById(id: number): Observable { - const coder = this.codersSubject.value.find(c => c.id === id); - return of(coder); - } - - /** - * Creates a new coder - * @param coder The coder to create - */ createCoder(coder: Omit): Observable { const newCoder: Coder = { ...coder, @@ -83,11 +66,6 @@ export class CoderService { return of(newCoder); } - /** - * Updates an existing coder - * @param id The ID of the coder to update - * @param coder The updated coder data - */ updateCoder(id: number, coder: Partial): Observable { const coders = this.codersSubject.value; const index = coders.findIndex(c => c.id === id); @@ -132,32 +110,51 @@ export class CoderService { * @param jobId The ID of the coding job */ assignJob(coderId: number, jobId: number): Observable { - const coders = this.codersSubject.value; - const index = coders.findIndex(c => c.id === coderId); - - if (index === -1) { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); return of(undefined); } - const coder = coders[index]; - const assignedJobs = coder.assignedJobs || []; + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coding-jobs/${jobId}/assign/${coderId}`; - // Only add the job if it's not already assigned - if (!assignedJobs.includes(jobId)) { - const updatedCoder: Coder = { - ...coder, - assignedJobs: [...assignedJobs, jobId] - }; + return this.http.post<{ success: boolean }>(url, {}).pipe( + map(() => { + // Update the local state after successful assignment + const coders = this.codersSubject.value; + const index = coders.findIndex(c => c.id === coderId); - const updatedCoders = [...coders]; - updatedCoders[index] = updatedCoder; + if (index === -1) { + return undefined; + } - this.codersSubject.next(updatedCoders); + const coder = coders[index]; + const assignedJobs = coder.assignedJobs || []; - return of(updatedCoder); - } + // Only add the job if it's not already assigned + if (!assignedJobs.includes(jobId)) { + const updatedCoder: Coder = { + ...coder, + assignedJobs: [...assignedJobs, jobId] + }; + + const updatedCoders = [...coders]; + updatedCoders[index] = updatedCoder; + + this.codersSubject.next(updatedCoders); - return of(coder); + return updatedCoder; + } + + return coder; + }), + catchError(error => { + console.error(`Error assigning job ${jobId} to coder ${coderId}:`, error); + return of(undefined); + }) + ); } /** @@ -166,27 +163,46 @@ export class CoderService { * @param jobId The ID of the coding job */ unassignJob(coderId: number, jobId: number): Observable { - const coders = this.codersSubject.value; - const index = coders.findIndex(c => c.id === coderId); - - if (index === -1) { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); return of(undefined); } - const coder = coders[index]; - const assignedJobs = coder.assignedJobs || []; + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coding-jobs/${jobId}/unassign/${coderId}`; - const updatedCoder: Coder = { - ...coder, - assignedJobs: assignedJobs.filter(id => id !== jobId) - }; + return this.http.delete<{ success: boolean }>(url).pipe( + map(() => { + // Update the local state after successful unassignment + const coders = this.codersSubject.value; + const index = coders.findIndex(c => c.id === coderId); - const updatedCoders = [...coders]; - updatedCoders[index] = updatedCoder; + if (index === -1) { + return undefined; + } - this.codersSubject.next(updatedCoders); + const coder = coders[index]; + const assignedJobs = coder.assignedJobs || []; - return of(updatedCoder); + const updatedCoder: Coder = { + ...coder, + assignedJobs: assignedJobs.filter(id => id !== jobId) + }; + + const updatedCoders = [...coders]; + updatedCoders[index] = updatedCoder; + + this.codersSubject.next(updatedCoders); + + return updatedCoder; + }), + catchError(error => { + console.error(`Error unassigning job ${jobId} from coder ${coderId}:`, error); + return of(undefined); + }) + ); } /** @@ -194,10 +210,67 @@ export class CoderService { * @param jobId The ID of the coding job */ getCodersByJobId(jobId: number): Observable { - const coders = this.codersSubject.value.filter( - coder => coder.assignedJobs?.includes(jobId) + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); + return of([]); + } + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/coding-jobs/${jobId}/coders`; + + interface WorkspaceUser { + userId: number; + workspaceId: number; + accessLevel: number; + username: string; + } + + return this.http.get<{ data: WorkspaceUser[], total: number }>(url).pipe( + map(response => { + // Map WorkspaceUser objects to Coder objects + const fetchedCoders: Coder[] = response.data.map(user => ({ + id: user.userId, + name: user.username || `User ${user.userId}`, + displayName: user.username || `Coder ${user.userId}`, + assignedJobs: [jobId] + })); + + // Merge with existing coders to maintain other properties + const existingCoders = this.codersSubject.value; + const mergedCoders = [...existingCoders]; + + fetchedCoders.forEach(fetchedCoder => { + const index = mergedCoders.findIndex(c => c.id === fetchedCoder.id); + if (index !== -1) { + // Update existing coder + mergedCoders[index] = { + ...mergedCoders[index], + ...fetchedCoder, + assignedJobs: [...(mergedCoders[index].assignedJobs || []), jobId] + }; + } else { + // Add new coder + mergedCoders.push(fetchedCoder); + } + }); + + // Update the subject with the merged coders + this.codersSubject.next(mergedCoders); + + return fetchedCoders; + }), + catchError(error => { + console.error(`Error fetching coders for job ${jobId}:`, error); + + // Fallback to local data if API call fails + const coders = this.codersSubject.value.filter( + coder => coder.assignedJobs?.includes(jobId) + ); + return of(coders); + }) ); - return of(coders); } /** diff --git a/apps/frontend/src/app/coding/services/coding-job.service.ts b/apps/frontend/src/app/coding/services/coding-job.service.ts new file mode 100644 index 000000000..e1ad25be3 --- /dev/null +++ b/apps/frontend/src/app/coding/services/coding-job.service.ts @@ -0,0 +1,252 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + BehaviorSubject, Observable, catchError, map, of, tap +} from 'rxjs'; +import { CodingJob } from '../models/coding-job.model'; +import { SERVER_URL } from '../../injection-tokens'; +import { AppService } from '../../services/app.service'; + +@Injectable({ + providedIn: 'root' +}) +export class CodingJobService { + private http = inject(HttpClient); + private readonly serverUrl = inject(SERVER_URL); + private appService = inject(AppService); + private codingJobsSubject = new BehaviorSubject([]); + + /** + * Gets all coding jobs for the current workspace + */ + getCodingJobs(): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of([]); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs`; + + this.http.get<{ data: CodingJob[], total: number }>(url).subscribe({ + next: response => { + // Map the response data to CodingJob objects + const codingJobs: CodingJob[] = response.data.map(job => ({ + ...job, + createdAt: new Date(job.createdAt), + updatedAt: new Date(job.updatedAt) + })); + + // Update the subject with the fetched coding jobs + this.codingJobsSubject.next(codingJobs); + }, + error: () => { + // Keep the current value in case of error + } + }); + + // Return the observable from the subject + return this.codingJobsSubject.asObservable(); + } + + /** + * Gets a coding job by ID + * @param id The ID of the coding job + */ + getCodingJob(id: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${id}`; + + return this.http.get(url).pipe( + map(job => ({ + ...job, + createdAt: new Date(job.createdAt), + updatedAt: new Date(job.updatedAt) + })), + catchError(() => of(undefined)) + ); + } + + /** + * Creates a new coding job + * @param job The coding job to create + */ + createCodingJob(job: Omit): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs`; + + return this.http.post(url, job).pipe( + map(newJob => ({ + ...newJob, + createdAt: new Date(newJob.createdAt), + updatedAt: new Date(newJob.updatedAt) + })), + tap(newJob => { + if (newJob) { + const currentJobs = this.codingJobsSubject.value; + this.codingJobsSubject.next([...currentJobs, newJob]); + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Updates a coding job + * @param id The ID of the coding job to update + * @param job The updated coding job data + */ + updateCodingJob(id: number, job: Partial): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${id}`; + + return this.http.put(url, job).pipe( + map(updatedJob => ({ + ...updatedJob, + createdAt: new Date(updatedJob.createdAt), + updatedAt: new Date(updatedJob.updatedAt) + })), + tap(updatedJob => { + if (updatedJob) { + const currentJobs = this.codingJobsSubject.value; + const index = currentJobs.findIndex(j => j.id === id); + if (index !== -1) { + const updatedJobs = [...currentJobs]; + updatedJobs[index] = updatedJob; + this.codingJobsSubject.next(updatedJobs); + } + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Deletes a coding job + * @param id The ID of the coding job to delete + */ + deleteCodingJob(id: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(false); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${id}`; + + return this.http.delete<{ success: boolean }>(url).pipe( + map(response => response.success), + tap(success => { + if (success) { + const currentJobs = this.codingJobsSubject.value; + this.codingJobsSubject.next(currentJobs.filter(job => job.id !== id)); + } + }), + catchError(() => of(false)) + ); + } + + /** + * Assigns a coder to a coding job + * @param codingJobId The ID of the coding job + * @param coderId The ID of the coder + */ + assignCoder(codingJobId: number, coderId: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${codingJobId}/assign/${coderId}`; + + return this.http.post(url, {}).pipe( + map(updatedJob => ({ + ...updatedJob, + createdAt: new Date(updatedJob.createdAt), + updatedAt: new Date(updatedJob.updatedAt) + })), + tap(updatedJob => { + if (updatedJob) { + const currentJobs = this.codingJobsSubject.value; + const index = currentJobs.findIndex(j => j.id === codingJobId); + if (index !== -1) { + const updatedJobs = [...currentJobs]; + updatedJobs[index] = updatedJob; + this.codingJobsSubject.next(updatedJobs); + } + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Unassigns a coder from a coding job + * @param codingJobId The ID of the coding job + * @param coderId The ID of the coder + */ + unassignCoder(codingJobId: number, coderId: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coding-jobs/${codingJobId}/assign/${coderId}`; + + return this.http.delete(url).pipe( + map(updatedJob => ({ + ...updatedJob, + createdAt: new Date(updatedJob.createdAt), + updatedAt: new Date(updatedJob.updatedAt) + })), + tap(updatedJob => { + if (updatedJob) { + const currentJobs = this.codingJobsSubject.value; + const index = currentJobs.findIndex(j => j.id === codingJobId); + if (index !== -1) { + const updatedJobs = [...currentJobs]; + updatedJobs[index] = updatedJob; + this.codingJobsSubject.next(updatedJobs); + } + } + }), + catchError(() => of(undefined)) + ); + } + + /** + * Gets all coding jobs assigned to a coder + * @param coderId The ID of the coder + */ + getCodingJobsByCoder(coderId: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + console.error('No workspace ID available'); + return of([]); + } + + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coders/${coderId}/coding-jobs`; + + return this.http.get<{ data: CodingJob[] }>(url).pipe( + map(response => response.data.map(job => ({ + ...job, + createdAt: new Date(job.createdAt), + updatedAt: new Date(job.updatedAt) + }))), + catchError(error => { + console.error(`Error fetching coding jobs for coder ${coderId}:`, error); + return of([]); + }) + ); + } +} diff --git a/apps/frontend/src/app/coding/services/variable-bundle.service.ts b/apps/frontend/src/app/coding/services/variable-bundle.service.ts new file mode 100644 index 000000000..be83dddd1 --- /dev/null +++ b/apps/frontend/src/app/coding/services/variable-bundle.service.ts @@ -0,0 +1,369 @@ +import { Injectable, inject } from '@angular/core'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { catchError, map, take } from 'rxjs/operators'; +import { SERVER_URL } from '../../injection-tokens'; +import { AppService } from '../../services/app.service'; +import { VariableBundle, Variable } from '../models/coding-job.model'; + +@Injectable({ + providedIn: 'root' +}) +export class VariableBundleService { + private http = inject(HttpClient); + private readonly serverUrl = inject(SERVER_URL); + private appService = inject(AppService); + private bundleGroupsSubject = new BehaviorSubject([]); + + // Sample data for demonstration + private sampleBundleGroups: VariableBundle[] = [ + { + id: 1, + name: 'Mathematische Fähigkeiten', + description: 'Variablen zur Bewertung mathematischer Fähigkeiten', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-15'), + variables: [ + { unitName: 'math101', variableId: 'addition' }, + { unitName: 'math101', variableId: 'subtraction' }, + { unitName: 'math102', variableId: 'multiplication' } + ] + }, + { + id: 2, + name: 'Sprachliche Fähigkeiten', + description: 'Variablen zur Bewertung sprachlicher Fähigkeiten', + createdAt: new Date('2023-02-01'), + updatedAt: new Date('2023-02-15'), + variables: [ + { unitName: 'lang101', variableId: 'grammar' }, + { unitName: 'lang101', variableId: 'vocabulary' }, + { unitName: 'lang102', variableId: 'comprehension' } + ] + } + ]; + + constructor() { + // Initialize with sample data + this.bundleGroupsSubject.next(this.sampleBundleGroups); + } + + /** + * Gets all variable bundle groups + */ + getBundleGroups(): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of([]); + } + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle-groups`; + + return this.http.get<{ data: VariableBundle[], total: number }>(url).pipe( + map(response => { + const bundleGroups = response.data; + + // Update the subject with the fetched bundle groups + this.bundleGroupsSubject.next(bundleGroups); + + return bundleGroups; + }), + catchError(() => this.bundleGroupsSubject.asObservable().pipe( + take(1) + ) + ) + ); + } + + /** + * Gets a variable bundle group by ID + * @param id The ID of the bundle group + */ + getBundleGroupById(id: number): Observable { + const bundleGroups = this.bundleGroupsSubject.value; + const bundleGroup = bundleGroups.find(group => group.id === id); + return of(bundleGroup); + } + + /** + * Creates a new variable bundle group + * @param bundleGroup The bundle group to create (without ID) + */ + createBundleGroup(bundleGroup: Omit): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of({ + ...bundleGroup, + id: this.getNextId() + } as VariableBundle); + } + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle-groups`; + + return this.http.post(url, bundleGroup).pipe( + map(newBundleGroup => { + // Update the local state with the new bundle group + const updatedBundleGroups = [...this.bundleGroupsSubject.value, newBundleGroup]; + this.bundleGroupsSubject.next(updatedBundleGroups); + + return newBundleGroup; + }), + catchError(() => { + // Fallback to local creation if API call fails + const newBundleGroup: VariableBundle = { + ...bundleGroup, + id: this.getNextId() + }; + + const updatedBundleGroups = [...this.bundleGroupsSubject.value, newBundleGroup]; + this.bundleGroupsSubject.next(updatedBundleGroups); + + return of(newBundleGroup); + }) + ); + } + + /** + * Updates an existing variable bundle group + * @param id The ID of the bundle group to update + * @param bundleGroup The updated bundle group data + */ + updateBundleGroup(id: number, bundleGroup: Partial): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle-groups/${id}`; + + // Ensure updatedAt is set to current date + const updateData = { + ...bundleGroup, + updatedAt: new Date() + }; + + return this.http.put(url, updateData).pipe( + map(updatedBundleGroup => { + // Update the local state with the updated bundle group + const bundleGroups = this.bundleGroupsSubject.value; + const index = bundleGroups.findIndex(group => group.id === id); + + if (index !== -1) { + const updatedBundleGroups = [...bundleGroups]; + updatedBundleGroups[index] = updatedBundleGroup; + this.bundleGroupsSubject.next(updatedBundleGroups); + } + + return updatedBundleGroup; + }), + catchError(() => { + // Fallback to local update if API call fails + const bundleGroups = this.bundleGroupsSubject.value; + const index = bundleGroups.findIndex(group => group.id === id); + + if (index === -1) { + return of(undefined); + } + + const updatedBundleGroup: VariableBundle = { + ...bundleGroups[index], + ...bundleGroup, + updatedAt: new Date() + }; + + const updatedBundleGroups = [...bundleGroups]; + updatedBundleGroups[index] = updatedBundleGroup; + + this.bundleGroupsSubject.next(updatedBundleGroups); + + return of(updatedBundleGroup); + }) + ); + } + + /** + * Deletes a variable bundle group + * @param id The ID of the bundle group to delete + */ + deleteBundleGroup(id: number): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(false); + } + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle-groups/${id}`; + + return this.http.delete<{ success: boolean }>(url).pipe( + map(response => { + if (response.success) { + // Update the local state by removing the deleted bundle group + const bundleGroups = this.bundleGroupsSubject.value; + const updatedBundleGroups = bundleGroups.filter(group => group.id !== id); + this.bundleGroupsSubject.next(updatedBundleGroups); + } + + return response.success; + }), + catchError(() => { + // Fallback to local deletion if API call fails + const bundleGroups = this.bundleGroupsSubject.value; + const updatedBundleGroups = bundleGroups.filter(group => group.id !== id); + + if (updatedBundleGroups.length === bundleGroups.length) { + return of(false); + } + + this.bundleGroupsSubject.next(updatedBundleGroups); + + return of(true); + }) + ); + } + + /** + * Adds a variable to a bundle group + * @param groupId The ID of the bundle group + * @param variable The variable to add + */ + addVariableToGroup(groupId: number, variable: Variable): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle-groups/${groupId}/variables`; + + return this.http.post(url, variable).pipe( + map(updatedGroup => { + // Update the local state with the updated bundle group + const bundleGroups = this.bundleGroupsSubject.value; + const index = bundleGroups.findIndex(group => group.id === groupId); + + if (index !== -1) { + const updatedBundleGroups = [...bundleGroups]; + updatedBundleGroups[index] = updatedGroup; + this.bundleGroupsSubject.next(updatedBundleGroups); + } + + return updatedGroup; + }), + catchError(() => { + // Fallback to local addition if API call fails + const bundleGroups = this.bundleGroupsSubject.value; + const index = bundleGroups.findIndex(group => group.id === groupId); + + if (index === -1) { + return of(undefined); + } + + const group = bundleGroups[index]; + + // Check if the variable already exists in the group + const variableExists = group.variables.some( + v => v.unitName === variable.unitName && v.variableId === variable.variableId + ); + + if (variableExists) { + return of(group); + } + + const updatedGroup: VariableBundle = { + ...group, + variables: [...group.variables, variable as Variable], + updatedAt: new Date() + }; + + const updatedBundleGroups = [...bundleGroups]; + updatedBundleGroups[index] = updatedGroup; + + this.bundleGroupsSubject.next(updatedBundleGroups); + + return of(updatedGroup); + }) + ); + } + + /** + * Removes a variable from a bundle group + * @param groupId The ID of the bundle group + * @param variable The variable to remove + */ + removeVariableFromGroup(groupId: number, variable: Variable): Observable { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return of(undefined); + } + + // Encode the variable parameters for the URL + const encodedUnitName = encodeURIComponent((variable as Variable).unitName); + const encodedVariableId = encodeURIComponent((variable as Variable).variableId); + + // Remove trailing slash from serverUrl if present to avoid double slashes + const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; + const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle-groups/${groupId}/variables/${encodedUnitName}/${encodedVariableId}`; + + return this.http.delete(url).pipe( + map(updatedGroup => { + // Update the local state with the updated bundle group + const bundleGroups = this.bundleGroupsSubject.value; + const index = bundleGroups.findIndex(group => group.id === groupId); + + if (index !== -1) { + const updatedBundleGroups = [...bundleGroups]; + updatedBundleGroups[index] = updatedGroup; + this.bundleGroupsSubject.next(updatedBundleGroups); + } + + return updatedGroup; + }), + catchError(() => { + // Fallback to local removal if API call fails + const bundleGroups = this.bundleGroupsSubject.value; + const index = bundleGroups.findIndex(group => group.id === groupId); + + if (index === -1) { + return of(undefined); + } + + const group = bundleGroups[index]; + + const updatedVariables = group.variables.filter( + v => !(v.unitName === (variable as Variable).unitName && v.variableId === (variable as Variable).variableId) + ); + + const updatedGroup: VariableBundle = { + ...group, + variables: updatedVariables, + updatedAt: new Date() + }; + + const updatedBundleGroups = [...bundleGroups]; + updatedBundleGroups[index] = updatedGroup; + + this.bundleGroupsSubject.next(updatedBundleGroups); + + return of(updatedGroup); + }) + ); + } + + /** + * Gets the next available ID for a new bundle group + */ + private getNextId(): number { + const bundleGroups = this.bundleGroupsSubject.value; + return bundleGroups.length > 0 ? + Math.max(...bundleGroups.map(group => group.id)) + 1 : + 1; + } +} diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql new file mode 100644 index 000000000..f1f1582d2 --- /dev/null +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -0,0 +1,94 @@ +-- liquibase formatted sql + +-- changeset jurei733:1 +CREATE TABLE IF NOT EXISTS "job" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "status" VARCHAR(255) NOT NULL, + "progress" INTEGER NULL, + "error" VARCHAR(255) NULL, + "result" TEXT NULL, + "type" VARCHAR(255) NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- rollback DROP TABLE IF EXISTS "job"; + +-- changeset jurei733:2 +CREATE TABLE IF NOT EXISTS "coding_job" ( + "id" INTEGER PRIMARY KEY REFERENCES "job"("id") ON DELETE CASCADE, + "name" VARCHAR(255) NOT NULL, + "description" TEXT NULL +); +-- rollback DROP TABLE IF EXISTS "coding_job"; + +-- changeset jurei733:3 +CREATE INDEX IF NOT EXISTS "idx_job_workspace_id" ON "job"("workspace_id"); +CREATE INDEX IF NOT EXISTS "idx_job_type" ON "job"("type"); +CREATE INDEX IF NOT EXISTS "idx_coding_job_name" ON "coding_job"("name"); +-- rollback DROP INDEX IF EXISTS "idx_job_workspace_id"; +-- rollback DROP INDEX IF EXISTS "idx_job_type"; +-- rollback DROP INDEX IF EXISTS "idx_coding_job_name"; + +-- changeset jurei733:4 +CREATE TABLE IF NOT EXISTS "variable" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "unit_name" VARCHAR(255) NOT NULL, + "variable_id" VARCHAR(255) NOT NULL +); +-- rollback DROP TABLE IF EXISTS "variable"; + +-- changeset jurei733:5 +CREATE TABLE IF NOT EXISTS "variable_bundle" ( + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL, + "description" TEXT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- rollback DROP TABLE IF EXISTS "variable_bundle"; + +-- changeset jurei733:6 +CREATE TABLE IF NOT EXISTS "coding_job_variable" ( + "coding_job_id" INTEGER NOT NULL REFERENCES "coding_job"("id") ON DELETE CASCADE, + "variable_id" INTEGER NOT NULL REFERENCES "variable"("id") ON DELETE CASCADE, + PRIMARY KEY ("coding_job_id", "variable_id") +); +-- rollback DROP TABLE IF EXISTS "coding_job_variable"; + +-- changeset jurei733:7 +CREATE TABLE IF NOT EXISTS "coding_job_variable_bundle" ( + "coding_job_id" INTEGER NOT NULL REFERENCES "coding_job"("id") ON DELETE CASCADE, + "variable_bundle_id" INTEGER NOT NULL REFERENCES "variable_bundle"("id") ON DELETE CASCADE, + PRIMARY KEY ("coding_job_id", "variable_bundle_id") +); +-- rollback DROP TABLE IF EXISTS "coding_job_variable_bundle"; + +-- changeset jurei733:8 +CREATE TABLE IF NOT EXISTS "variable_bundle_variables" ( + "bundle_id" INTEGER NOT NULL REFERENCES "variable_bundle"("id") ON DELETE CASCADE, + "variable_bundle_id" INTEGER NOT NULL REFERENCES "variable"("id") ON DELETE CASCADE, + PRIMARY KEY ("bundle_id", "variable_bundle_id") +); +-- rollback DROP TABLE IF EXISTS "variable_bundle_variables"; + +-- changeset jurei733:9 +CREATE INDEX IF NOT EXISTS "idx_variable_workspace_id" ON "variable"("workspace_id"); +CREATE INDEX IF NOT EXISTS "idx_variable_bundle_workspace_id" ON "variable_bundle"("workspace_id"); +-- rollback DROP INDEX IF EXISTS "idx_variable_workspace_id"; +-- rollback DROP INDEX IF EXISTS "idx_variable_bundle_workspace_id"; + +-- changeset jurei733:10 +CREATE TABLE IF NOT EXISTS "coding_job_coders" ( + "coding_job_id" INTEGER NOT NULL REFERENCES "coding_job"("id") ON DELETE CASCADE, + "coder_id" INTEGER NOT NULL, + PRIMARY KEY ("coding_job_id", "coder_id") +); +-- rollback DROP TABLE IF EXISTS "coding_job_coders"; + +-- changeset jurei733:11 +ALTER TABLE "variable_bundle_variables" +RENAME COLUMN "variable_bundle_id" TO "variable_id"; +-- rollback ALTER TABLE "variable_bundle_variables" RENAME COLUMN "variable_id" TO "variable_bundle_id"; diff --git a/database/changelog/coding-box.changelog-root.xml b/database/changelog/coding-box.changelog-root.xml index 8d861d409..0e663988d 100644 --- a/database/changelog/coding-box.changelog-root.xml +++ b/database/changelog/coding-box.changelog-root.xml @@ -20,4 +20,5 @@ + From 4f40c314c78d646bcd321f6e67a322a453141d43 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:16:11 +0200 Subject: [PATCH 06/14] Schedule response cache at 2 am --- apps/backend/src/app/cache/response-cache-scheduler.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/cache/response-cache-scheduler.service.ts b/apps/backend/src/app/cache/response-cache-scheduler.service.ts index 921742787..88e2d6b2a 100644 --- a/apps/backend/src/app/cache/response-cache-scheduler.service.ts +++ b/apps/backend/src/app/cache/response-cache-scheduler.service.ts @@ -20,7 +20,7 @@ export class ResponseCacheSchedulerService { private readonly unitRepository: Repository ) {} - @Cron(CronExpression.EVERY_DAY_AT_1AM) + @Cron(CronExpression.EVERY_DAY_AT_2AM) async cacheAllResponses() { this.logger.log('Starting nightly task to cache all responses'); const startTime = Date.now(); From a8a78830f4e3f74bda24bf5f6923a4fbf11f01e6 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:16:43 +0200 Subject: [PATCH 07/14] Set version to 0.12.0 --- apps/frontend/src/app/components/home/home.component.html | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index 86e314cc7..d2f42972a 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -9,7 +9,7 @@ [appTitle]="'Web application for coding'" [introHtml]="'appService.appConfig.introHtml'" [appName]="'IQB-Kodierbox'" - [appVersion]="'0.11.1'" + [appVersion]="'0.11.2'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/package-lock.json b/package-lock.json index 0601473a4..36333b7bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.11.1", + "version": "0.11.2", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index 320567170..c774a9d04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.11.1", + "version": "0.11.2", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": { From 2cbece1e3f2a546e886b57d2ac697a6210679553 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:30:36 +0200 Subject: [PATCH 08/14] Update changelog files to ensure consistent use of the "public" schema and correcting migration sequences. --- .../changelog/coding-box.changelog-0.12.0.sql | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql index f1f1582d2..bf984cf02 100644 --- a/database/changelog/coding-box.changelog-0.12.0.sql +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -1,7 +1,7 @@ -- liquibase formatted sql -- changeset jurei733:1 -CREATE TABLE IF NOT EXISTS "job" ( +CREATE TABLE IF NOT EXISTS "public"."job" ( "id" SERIAL PRIMARY KEY, "workspace_id" INTEGER NOT NULL, "status" VARCHAR(255) NOT NULL, @@ -12,35 +12,35 @@ CREATE TABLE IF NOT EXISTS "job" ( "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); --- rollback DROP TABLE IF EXISTS "job"; +-- rollback DROP TABLE IF EXISTS "public"."job"; -- changeset jurei733:2 -CREATE TABLE IF NOT EXISTS "coding_job" ( - "id" INTEGER PRIMARY KEY REFERENCES "job"("id") ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS "public"."coding_job" ( + "id" INTEGER PRIMARY KEY REFERENCES "public"."job"("id") ON DELETE CASCADE, "name" VARCHAR(255) NOT NULL, "description" TEXT NULL ); --- rollback DROP TABLE IF EXISTS "coding_job"; +-- rollback DROP TABLE IF EXISTS "public"."coding_job"; -- changeset jurei733:3 -CREATE INDEX IF NOT EXISTS "idx_job_workspace_id" ON "job"("workspace_id"); -CREATE INDEX IF NOT EXISTS "idx_job_type" ON "job"("type"); -CREATE INDEX IF NOT EXISTS "idx_coding_job_name" ON "coding_job"("name"); --- rollback DROP INDEX IF EXISTS "idx_job_workspace_id"; --- rollback DROP INDEX IF EXISTS "idx_job_type"; --- rollback DROP INDEX IF EXISTS "idx_coding_job_name"; +CREATE INDEX IF NOT EXISTS "idx_job_workspace_id" ON "public"."job"("workspace_id"); +CREATE INDEX IF NOT EXISTS "idx_job_type" ON "public"."job"("type"); +CREATE INDEX IF NOT EXISTS "idx_coding_job_name" ON "public"."coding_job"("name"); +-- rollback DROP INDEX IF EXISTS "idx_job_workspace_id" ON "public"."job"; +-- rollback DROP INDEX IF EXISTS "idx_job_type" ON "public"."job"; +-- rollback DROP INDEX IF EXISTS "idx_coding_job_name" ON "public"."coding_job"; -- changeset jurei733:4 -CREATE TABLE IF NOT EXISTS "variable" ( +CREATE TABLE IF NOT EXISTS "public"."variable" ( "id" SERIAL PRIMARY KEY, "workspace_id" INTEGER NOT NULL, "unit_name" VARCHAR(255) NOT NULL, "variable_id" VARCHAR(255) NOT NULL ); --- rollback DROP TABLE IF EXISTS "variable"; +-- rollback DROP TABLE IF EXISTS "public"."variable"; -- changeset jurei733:5 -CREATE TABLE IF NOT EXISTS "variable_bundle" ( +CREATE TABLE IF NOT EXISTS "public"."variable_bundle" ( "id" SERIAL PRIMARY KEY, "workspace_id" INTEGER NOT NULL, "name" VARCHAR(255) NOT NULL, @@ -48,47 +48,47 @@ CREATE TABLE IF NOT EXISTS "variable_bundle" ( "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); --- rollback DROP TABLE IF EXISTS "variable_bundle"; +-- rollback DROP TABLE IF EXISTS "public"."variable_bundle"; -- changeset jurei733:6 -CREATE TABLE IF NOT EXISTS "coding_job_variable" ( - "coding_job_id" INTEGER NOT NULL REFERENCES "coding_job"("id") ON DELETE CASCADE, - "variable_id" INTEGER NOT NULL REFERENCES "variable"("id") ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS "public"."coding_job_variable" ( + "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, + "variable_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, PRIMARY KEY ("coding_job_id", "variable_id") ); --- rollback DROP TABLE IF EXISTS "coding_job_variable"; +-- rollback DROP TABLE IF EXISTS "public"."coding_job_variable"; -- changeset jurei733:7 -CREATE TABLE IF NOT EXISTS "coding_job_variable_bundle" ( - "coding_job_id" INTEGER NOT NULL REFERENCES "coding_job"("id") ON DELETE CASCADE, - "variable_bundle_id" INTEGER NOT NULL REFERENCES "variable_bundle"("id") ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS "public"."coding_job_variable_bundle" ( + "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, + "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, PRIMARY KEY ("coding_job_id", "variable_bundle_id") ); --- rollback DROP TABLE IF EXISTS "coding_job_variable_bundle"; +-- rollback DROP TABLE IF EXISTS "public"."coding_job_variable_bundle"; -- changeset jurei733:8 -CREATE TABLE IF NOT EXISTS "variable_bundle_variables" ( - "bundle_id" INTEGER NOT NULL REFERENCES "variable_bundle"("id") ON DELETE CASCADE, - "variable_bundle_id" INTEGER NOT NULL REFERENCES "variable"("id") ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS "public"."variable_bundle_variables" ( + "bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, + "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, PRIMARY KEY ("bundle_id", "variable_bundle_id") ); --- rollback DROP TABLE IF EXISTS "variable_bundle_variables"; +-- rollback DROP TABLE IF EXISTS "public"."variable_bundle_variables"; -- changeset jurei733:9 -CREATE INDEX IF NOT EXISTS "idx_variable_workspace_id" ON "variable"("workspace_id"); -CREATE INDEX IF NOT EXISTS "idx_variable_bundle_workspace_id" ON "variable_bundle"("workspace_id"); --- rollback DROP INDEX IF EXISTS "idx_variable_workspace_id"; --- rollback DROP INDEX IF EXISTS "idx_variable_bundle_workspace_id"; +CREATE INDEX IF NOT EXISTS "idx_variable_workspace_id" ON "public"."variable"("workspace_id"); +CREATE INDEX IF NOT EXISTS "idx_variable_bundle_workspace_id" ON "public"."variable_bundle"("workspace_id"); +-- rollback DROP INDEX IF EXISTS "idx_variable_workspace_id" ON "public"."variable"; +-- rollback DROP INDEX IF EXISTS "idx_variable_bundle_workspace_id" ON "public"."variable_bundle"; -- changeset jurei733:10 -CREATE TABLE IF NOT EXISTS "coding_job_coders" ( - "coding_job_id" INTEGER NOT NULL REFERENCES "coding_job"("id") ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS "public"."coding_job_coders" ( + "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, "coder_id" INTEGER NOT NULL, PRIMARY KEY ("coding_job_id", "coder_id") ); --- rollback DROP TABLE IF EXISTS "coding_job_coders"; +-- rollback DROP TABLE IF EXISTS "public"."coding_job_coders"; -- changeset jurei733:11 -ALTER TABLE "variable_bundle_variables" +ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_bundle_id" TO "variable_id"; --- rollback ALTER TABLE "variable_bundle_variables" RENAME COLUMN "variable_id" TO "variable_bundle_id"; +-- rollback ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_id" TO "variable_bundle_id"; From 1e9350c685f08889e3b6e78232b5240a288bd6d2 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:45:24 +0200 Subject: [PATCH 09/14] Remove job creation sql statement --- .../variable-bundle-manager.component.ts | 5 +-- .../changelog/coding-box.changelog-0.12.0.sql | 32 ++++++------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts index 850b29e65..b3c2d5f14 100644 --- a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts @@ -20,7 +20,7 @@ import { SelectionModel } from '@angular/cdk/collections'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatCheckbox } from '@angular/material/checkbox'; -import { MatAnchor, MatButton } from '@angular/material/button'; +import { MatAnchor, MatButton, MatIconButton } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; import { VariableBundle } from '../../models/coding-job.model'; @@ -55,7 +55,8 @@ import { VariableBundleDialogComponent } from '../variable-bundle-dialog/variabl MatSortModule, MatButton, MatDialogModule, - MatTooltipModule + MatTooltipModule, + MatIconButton ] }) export class VariableBundleManagerComponent implements OnInit, AfterViewInit { diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql index bf984cf02..2efca6957 100644 --- a/database/changelog/coding-box.changelog-0.12.0.sql +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -1,20 +1,6 @@ -- liquibase formatted sql -- changeset jurei733:1 -CREATE TABLE IF NOT EXISTS "public"."job" ( - "id" SERIAL PRIMARY KEY, - "workspace_id" INTEGER NOT NULL, - "status" VARCHAR(255) NOT NULL, - "progress" INTEGER NULL, - "error" VARCHAR(255) NULL, - "result" TEXT NULL, - "type" VARCHAR(255) NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); --- rollback DROP TABLE IF EXISTS "public"."job"; - --- changeset jurei733:2 CREATE TABLE IF NOT EXISTS "public"."coding_job" ( "id" INTEGER PRIMARY KEY REFERENCES "public"."job"("id") ON DELETE CASCADE, "name" VARCHAR(255) NOT NULL, @@ -22,7 +8,7 @@ CREATE TABLE IF NOT EXISTS "public"."coding_job" ( ); -- rollback DROP TABLE IF EXISTS "public"."coding_job"; --- changeset jurei733:3 +-- changeset jurei733:2 CREATE INDEX IF NOT EXISTS "idx_job_workspace_id" ON "public"."job"("workspace_id"); CREATE INDEX IF NOT EXISTS "idx_job_type" ON "public"."job"("type"); CREATE INDEX IF NOT EXISTS "idx_coding_job_name" ON "public"."coding_job"("name"); @@ -30,7 +16,7 @@ CREATE INDEX IF NOT EXISTS "idx_coding_job_name" ON "public"."coding_job"("name" -- rollback DROP INDEX IF EXISTS "idx_job_type" ON "public"."job"; -- rollback DROP INDEX IF EXISTS "idx_coding_job_name" ON "public"."coding_job"; --- changeset jurei733:4 +-- changeset jurei733:3 CREATE TABLE IF NOT EXISTS "public"."variable" ( "id" SERIAL PRIMARY KEY, "workspace_id" INTEGER NOT NULL, @@ -39,7 +25,7 @@ CREATE TABLE IF NOT EXISTS "public"."variable" ( ); -- rollback DROP TABLE IF EXISTS "public"."variable"; --- changeset jurei733:5 +-- changeset jurei733:4 CREATE TABLE IF NOT EXISTS "public"."variable_bundle" ( "id" SERIAL PRIMARY KEY, "workspace_id" INTEGER NOT NULL, @@ -50,7 +36,7 @@ CREATE TABLE IF NOT EXISTS "public"."variable_bundle" ( ); -- rollback DROP TABLE IF EXISTS "public"."variable_bundle"; --- changeset jurei733:6 +-- changeset jurei733:5 CREATE TABLE IF NOT EXISTS "public"."coding_job_variable" ( "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, "variable_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, @@ -58,7 +44,7 @@ CREATE TABLE IF NOT EXISTS "public"."coding_job_variable" ( ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_variable"; --- changeset jurei733:7 +-- changeset jurei733:6 CREATE TABLE IF NOT EXISTS "public"."coding_job_variable_bundle" ( "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, @@ -66,7 +52,7 @@ CREATE TABLE IF NOT EXISTS "public"."coding_job_variable_bundle" ( ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_variable_bundle"; --- changeset jurei733:8 +-- changeset jurei733:7 CREATE TABLE IF NOT EXISTS "public"."variable_bundle_variables" ( "bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, @@ -74,13 +60,13 @@ CREATE TABLE IF NOT EXISTS "public"."variable_bundle_variables" ( ); -- rollback DROP TABLE IF EXISTS "public"."variable_bundle_variables"; --- changeset jurei733:9 +-- changeset jurei733:8 CREATE INDEX IF NOT EXISTS "idx_variable_workspace_id" ON "public"."variable"("workspace_id"); CREATE INDEX IF NOT EXISTS "idx_variable_bundle_workspace_id" ON "public"."variable_bundle"("workspace_id"); -- rollback DROP INDEX IF EXISTS "idx_variable_workspace_id" ON "public"."variable"; -- rollback DROP INDEX IF EXISTS "idx_variable_bundle_workspace_id" ON "public"."variable_bundle"; --- changeset jurei733:10 +-- changeset jurei733:9 CREATE TABLE IF NOT EXISTS "public"."coding_job_coders" ( "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, "coder_id" INTEGER NOT NULL, @@ -88,7 +74,7 @@ CREATE TABLE IF NOT EXISTS "public"."coding_job_coders" ( ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_coders"; --- changeset jurei733:11 +-- changeset jurei733:10 ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_bundle_id" TO "variable_id"; -- rollback ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_id" TO "variable_bundle_id"; From 377338393f778fc9af2b3f9d502075589f4181b2 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:57:37 +0200 Subject: [PATCH 10/14] Remove public job reference --- database/changelog/coding-box.changelog-0.12.0.sql | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql index 2efca6957..1315560bf 100644 --- a/database/changelog/coding-box.changelog-0.12.0.sql +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -2,18 +2,14 @@ -- changeset jurei733:1 CREATE TABLE IF NOT EXISTS "public"."coding_job" ( - "id" INTEGER PRIMARY KEY REFERENCES "public"."job"("id") ON DELETE CASCADE, + "id" SERIAL PRIMARY KEY, "name" VARCHAR(255) NOT NULL, "description" TEXT NULL ); -- rollback DROP TABLE IF EXISTS "public"."coding_job"; -- changeset jurei733:2 -CREATE INDEX IF NOT EXISTS "idx_job_workspace_id" ON "public"."job"("workspace_id"); -CREATE INDEX IF NOT EXISTS "idx_job_type" ON "public"."job"("type"); CREATE INDEX IF NOT EXISTS "idx_coding_job_name" ON "public"."coding_job"("name"); --- rollback DROP INDEX IF EXISTS "idx_job_workspace_id" ON "public"."job"; --- rollback DROP INDEX IF EXISTS "idx_job_type" ON "public"."job"; -- rollback DROP INDEX IF EXISTS "idx_coding_job_name" ON "public"."coding_job"; -- changeset jurei733:3 From b7ae8ff4be27a15c2831569c713d894c94894bc1 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 01:23:14 +0200 Subject: [PATCH 11/14] Remove indexes --- database/changelog/coding-box.changelog-0.12.0.sql | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql index 1315560bf..d28947c78 100644 --- a/database/changelog/coding-box.changelog-0.12.0.sql +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -2,7 +2,7 @@ -- changeset jurei733:1 CREATE TABLE IF NOT EXISTS "public"."coding_job" ( - "id" SERIAL PRIMARY KEY, + "id" INTEGER PRIMARY KEY REFERENCES "public"."job"("id") ON DELETE CASCADE, "name" VARCHAR(255) NOT NULL, "description" TEXT NULL ); @@ -57,12 +57,6 @@ CREATE TABLE IF NOT EXISTS "public"."variable_bundle_variables" ( -- rollback DROP TABLE IF EXISTS "public"."variable_bundle_variables"; -- changeset jurei733:8 -CREATE INDEX IF NOT EXISTS "idx_variable_workspace_id" ON "public"."variable"("workspace_id"); -CREATE INDEX IF NOT EXISTS "idx_variable_bundle_workspace_id" ON "public"."variable_bundle"("workspace_id"); --- rollback DROP INDEX IF EXISTS "idx_variable_workspace_id" ON "public"."variable"; --- rollback DROP INDEX IF EXISTS "idx_variable_bundle_workspace_id" ON "public"."variable_bundle"; - --- changeset jurei733:9 CREATE TABLE IF NOT EXISTS "public"."coding_job_coders" ( "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, "coder_id" INTEGER NOT NULL, @@ -70,7 +64,7 @@ CREATE TABLE IF NOT EXISTS "public"."coding_job_coders" ( ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_coders"; --- changeset jurei733:10 +-- changeset jurei733:9 ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_bundle_id" TO "variable_id"; -- rollback ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_id" TO "variable_bundle_id"; From 044d02a5f1fd026f79521e692798c12d9f9c99e8 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 02:14:45 +0200 Subject: [PATCH 12/14] Fix changelog 0.12. --- .../changelog/coding-box.changelog-0.12.0.sql | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql index d28947c78..791fc9ba6 100644 --- a/database/changelog/coding-box.changelog-0.12.0.sql +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -2,9 +2,9 @@ -- changeset jurei733:1 CREATE TABLE IF NOT EXISTS "public"."coding_job" ( - "id" INTEGER PRIMARY KEY REFERENCES "public"."job"("id") ON DELETE CASCADE, - "name" VARCHAR(255) NOT NULL, - "description" TEXT NULL + "id" INTEGER PRIMARY KEY REFERENCES "public"."job"("id") ON DELETE CASCADE, + "name" VARCHAR(255) NOT NULL, + "description" TEXT NULL ); -- rollback DROP TABLE IF EXISTS "public"."coding_job"; @@ -14,57 +14,57 @@ CREATE INDEX IF NOT EXISTS "idx_coding_job_name" ON "public"."coding_job"("name" -- changeset jurei733:3 CREATE TABLE IF NOT EXISTS "public"."variable" ( - "id" SERIAL PRIMARY KEY, - "workspace_id" INTEGER NOT NULL, - "unit_name" VARCHAR(255) NOT NULL, - "variable_id" VARCHAR(255) NOT NULL + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "unit_name" VARCHAR(255) NOT NULL, + "variable_id" VARCHAR(255) NOT NULL ); -- rollback DROP TABLE IF EXISTS "public"."variable"; -- changeset jurei733:4 CREATE TABLE IF NOT EXISTS "public"."variable_bundle" ( - "id" SERIAL PRIMARY KEY, - "workspace_id" INTEGER NOT NULL, - "name" VARCHAR(255) NOT NULL, - "description" TEXT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + "id" SERIAL PRIMARY KEY, + "workspace_id" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL, + "description" TEXT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- rollback DROP TABLE IF EXISTS "public"."variable_bundle"; -- changeset jurei733:5 CREATE TABLE IF NOT EXISTS "public"."coding_job_variable" ( - "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, - "variable_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, - PRIMARY KEY ("coding_job_id", "variable_id") + "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, + "variable_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, + PRIMARY KEY ("coding_job_id", "variable_id") ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_variable"; -- changeset jurei733:6 CREATE TABLE IF NOT EXISTS "public"."coding_job_variable_bundle" ( - "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, - "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, - PRIMARY KEY ("coding_job_id", "variable_bundle_id") + "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, + "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, + PRIMARY KEY ("coding_job_id", "variable_bundle_id") ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_variable_bundle"; -- changeset jurei733:7 CREATE TABLE IF NOT EXISTS "public"."variable_bundle_variables" ( - "bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, - "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, - PRIMARY KEY ("bundle_id", "variable_bundle_id") + "bundle_id" INTEGER NOT NULL REFERENCES "public"."variable_bundle"("id") ON DELETE CASCADE, + "variable_bundle_id" INTEGER NOT NULL REFERENCES "public"."variable"("id") ON DELETE CASCADE, + PRIMARY KEY ("bundle_id", "variable_bundle_id") ); -- rollback DROP TABLE IF EXISTS "public"."variable_bundle_variables"; -- changeset jurei733:8 CREATE TABLE IF NOT EXISTS "public"."coding_job_coders" ( - "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, - "coder_id" INTEGER NOT NULL, - PRIMARY KEY ("coding_job_id", "coder_id") + "coding_job_id" INTEGER NOT NULL REFERENCES "public"."coding_job"("id") ON DELETE CASCADE, + "coder_id" INTEGER NOT NULL, + PRIMARY KEY ("coding_job_id", "coder_id") ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_coders"; -- changeset jurei733:9 ALTER TABLE "public"."variable_bundle_variables" -RENAME COLUMN "variable_bundle_id" TO "variable_id"; + RENAME COLUMN "variable_bundle_id" TO "variable_id"; -- rollback ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_id" TO "variable_bundle_id"; From c6634ae9ce10c88af99421ed702c2f4926363b12 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 02:15:18 +0200 Subject: [PATCH 13/14] Reschedule response caching --- apps/backend/src/app/cache/response-cache-scheduler.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/cache/response-cache-scheduler.service.ts b/apps/backend/src/app/cache/response-cache-scheduler.service.ts index 88e2d6b2a..783f28e90 100644 --- a/apps/backend/src/app/cache/response-cache-scheduler.service.ts +++ b/apps/backend/src/app/cache/response-cache-scheduler.service.ts @@ -20,7 +20,7 @@ export class ResponseCacheSchedulerService { private readonly unitRepository: Repository ) {} - @Cron(CronExpression.EVERY_DAY_AT_2AM) + @Cron(CronExpression.EVERY_DAY_AT_4AM) async cacheAllResponses() { this.logger.log('Starting nightly task to cache all responses'); const startTime = Date.now(); From ba9eef179330c539717f6bb047d1ab6c494c1c0f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:50:59 +0200 Subject: [PATCH 14/14] Fix changeset remove the rename column operation --- database/changelog/coding-box.changelog-0.12.0.sql | 5 ----- 1 file changed, 5 deletions(-) diff --git a/database/changelog/coding-box.changelog-0.12.0.sql b/database/changelog/coding-box.changelog-0.12.0.sql index 791fc9ba6..9b4666fed 100644 --- a/database/changelog/coding-box.changelog-0.12.0.sql +++ b/database/changelog/coding-box.changelog-0.12.0.sql @@ -63,8 +63,3 @@ CREATE TABLE IF NOT EXISTS "public"."coding_job_coders" ( PRIMARY KEY ("coding_job_id", "coder_id") ); -- rollback DROP TABLE IF EXISTS "public"."coding_job_coders"; - --- changeset jurei733:9 -ALTER TABLE "public"."variable_bundle_variables" - RENAME COLUMN "variable_bundle_id" TO "variable_id"; --- rollback ALTER TABLE "public"."variable_bundle_variables" RENAME COLUMN "variable_id" TO "variable_bundle_id";