diff --git a/.prettierignore b/.prettierignore index 8f8e33c6..85375be3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ /node_modules /tmp /.idea +/development/volumes diff --git a/CHANGELOG.md b/CHANGELOG.md index b42a8c1e..01b8b669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ - Direktauswahl von Assets ohne Suche - Testing - Regeneriere Elasticsearch Index via Admin Panel +- Einteilung von Assets in Arbeitsgruppen ### Changed - UI Refactoring: Neuanordnung der Container - UI Refactoring: Suchergebnisse als Tabelle - Update Dependencies +- Bearbeitungsrechte werden auf Basis der Arbeitsgruppen vergeben anstatt global ### Fixed diff --git a/apps/client-asset-sg/project.json b/apps/client-asset-sg/project.json index 609265e0..2d260188 100644 --- a/apps/client-asset-sg/project.json +++ b/apps/client-asset-sg/project.json @@ -19,7 +19,7 @@ "assets": ["apps/client-asset-sg/src/favicon.ico", "apps/client-asset-sg/src/assets"], "styles": ["apps/client-asset-sg/src/styles.scss"], "scripts": [], - "allowedCommonJsDependencies": ["tsafe", "validator", "xml-utils", "pbf", "rbush", "earcut"] + "allowedCommonJsDependencies": ["tsafe", "validator", "xml-utils", "pbf", "rbush", "earcut", "@prisma/client"] }, "configurations": { "production": { diff --git a/apps/client-asset-sg/src/app/app-guards.ts b/apps/client-asset-sg/src/app/app-guards.ts index b256ac11..bdfb4628 100644 --- a/apps/client-asset-sg/src/app/app-guards.ts +++ b/apps/client-asset-sg/src/app/app-guards.ts @@ -1,29 +1,16 @@ import { inject } from '@angular/core'; import { CanActivateFn } from '@angular/router'; import { fromAppShared } from '@asset-sg/client-shared'; -import { ORD } from '@asset-sg/core'; -import { User, isAdmin, isEditor } from '@asset-sg/shared'; +import { isNotNull } from '@asset-sg/core'; +import { User } from '@asset-sg/shared/v2'; import { Store } from '@ngrx/store'; -import * as E from 'fp-ts/Either'; -import { pipe } from 'fp-ts/function'; -import { map } from 'rxjs'; +import { filter, map } from 'rxjs'; import { AppState } from './state/app-state'; -export const roleGuard = (rolePredicate: (u: User) => boolean) => { +export const roleGuard = (testUser: (u: User) => boolean) => { const store = inject(Store); - return store.select(fromAppShared.selectRDUserProfile).pipe( - ORD.filterIsCompleteEither, - map((user) => - E.isRight( - pipe( - user, - E.filterOrElseW(rolePredicate, () => undefined) - ) - ) - ) - ); + return store.select(fromAppShared.selectUser).pipe(filter(isNotNull), map(testUser)); }; -export const adminGuard: CanActivateFn = () => roleGuard(isAdmin); -export const editorGuard: CanActivateFn = () => roleGuard(isEditor); +export const adminGuard: CanActivateFn = () => roleGuard((user) => user.isAdmin); diff --git a/apps/client-asset-sg/src/app/app.component.html b/apps/client-asset-sg/src/app/app.component.html index 845b793f..bb843f58 100644 --- a/apps/client-asset-sg/src/app/app.component.html +++ b/apps/client-asset-sg/src/app/app.component.html @@ -1,30 +1,36 @@ - +
{{ error }}
- - - + + + @if (authState === AuthState.Success || authState === AuthState.ForbiddenResource) { + + + + +
+ @if (authState === AuthState.Success) { + + } @else { +
+

resourceForbidden

+

403 - Forbidden

+
+ } +
+
+ +
+ } @else { + }
- - - - - -
- -
-
- -
-
-
    diff --git a/apps/client-asset-sg/src/app/app.component.scss b/apps/client-asset-sg/src/app/app.component.scss index fa938a57..28b5b0d7 100644 --- a/apps/client-asset-sg/src/app/app.component.scss +++ b/apps/client-asset-sg/src/app/app.component.scss @@ -29,6 +29,7 @@ asset-sg-menu-bar { .router-outlet { grid-area: router-outlet; display: grid; + router-outlet { display: none; } @@ -71,3 +72,24 @@ asset-sg-menu-bar { /// angular-cdk-overlay is at 1000, so we go to 1500. z-index: 1500; } + +.forbidden { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + + h1 { + font-size: 4rem; + margin: 0; + color: mat.get-color-from-palette($asset-sg-warn, 300); + } + + p { + font-size: 1.5rem; + margin: 0; + padding: 1rem 0 0 0; + color: mat.get-color-from-palette($asset-sg-warn, 200); + } +} diff --git a/apps/client-asset-sg/src/app/app.component.ts b/apps/client-asset-sg/src/app/app.component.ts index 6d0aebb0..f76cef43 100644 --- a/apps/client-asset-sg/src/app/app.component.ts +++ b/apps/client-asset-sg/src/app/app.component.ts @@ -41,6 +41,7 @@ export class AppComponent { await this.authService.signIn(); this.store.dispatch(appSharedStateActions.loadUserProfile()); this.store.dispatch(appSharedStateActions.loadReferenceData()); + this.store.dispatch(appSharedStateActions.loadWorkgroups()); }); const wndw = this._wndw; diff --git a/apps/client-asset-sg/src/app/app.module.ts b/apps/client-asset-sg/src/app/app.module.ts index a2827039..0deebf38 100644 --- a/apps/client-asset-sg/src/app/app.module.ts +++ b/apps/client-asset-sg/src/app/app.module.ts @@ -3,7 +3,7 @@ import { DialogModule } from '@angular/cdk/dialog'; import { CommonModule, NgOptimizedImage, registerLocaleData } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import locale_deCH from '@angular/common/locales/de-CH'; -import { NgModule, inject } from '@angular/core'; +import { inject, NgModule } from '@angular/core'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { BrowserModule } from '@angular/platform-browser'; @@ -11,18 +11,20 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule } from '@angular/router'; import { AuthInterceptor, AuthModule, ErrorService } from '@asset-sg/auth'; import { + AdminOnlyDirective, AlertModule, AnchorComponent, ButtonComponent, + CanCreateDirective, CURRENT_LANG, - TranslateTsLoader, currentLangFactory, icons, + TranslateTsLoader, } from '@asset-sg/client-shared'; import { storeLogger } from '@asset-sg/core'; -import { SvgIconComponent, provideSvgIcons } from '@ngneat/svg-icon'; +import { provideSvgIcons, SvgIconComponent } from '@ngneat/svg-icon'; import { EffectsModule } from '@ngrx/effects'; -import { FullRouterStateSerializer, StoreRouterConnectingModule, routerReducer } from '@ngrx/router-store'; +import { FullRouterStateSerializer, routerReducer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { StoreModule } from '@ngrx/store'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { ForModule } from '@rx-angular/template/for'; @@ -31,7 +33,7 @@ import { PushModule } from '@rx-angular/template/push'; import { environment } from '../environments/environment'; -import { adminGuard, editorGuard } from './app-guards'; +import { adminGuard } from './app-guards'; import { assetsPageMatcher } from './app-matchers'; import { AppComponent } from './app.component'; import { AppBarComponent, MenuBarComponent, NotFoundComponent, RedirectToLangComponent } from './components'; @@ -73,7 +75,6 @@ registerLocaleData(locale_deCH, 'de-CH'); { path: ':lang/asset-admin', loadChildren: () => import('@asset-sg/asset-editor').then((m) => m.AssetEditorModule), - canActivate: [editorGuard], }, { matcher: assetsPageMatcher, @@ -116,6 +117,8 @@ registerLocaleData(locale_deCH, 'de-CH'); AlertModule, NgOptimizedImage, MatProgressSpinnerModule, + AdminOnlyDirective, + CanCreateDirective, ], providers: [ provideSvgIcons(icons), diff --git a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html index 8fef2e51..53befdd1 100644 --- a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html +++ b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html @@ -24,7 +24,7 @@ menuBar.favourites - + - +

    {{ "accessForbidden" | translate }} diff --git a/apps/client-asset-sg/src/app/i18n/de.ts b/apps/client-asset-sg/src/app/i18n/de.ts index 8fd0d071..1c4a9c1a 100644 --- a/apps/client-asset-sg/src/app/i18n/de.ts +++ b/apps/client-asset-sg/src/app/i18n/de.ts @@ -2,6 +2,7 @@ export const deAppTranslations = { logoSwissGeol: 'Logo Swissgeol Assets', welcomeTo: 'Willkommen bei', accessForbidden: 'Sie haben keinen Zugriff auf diese Applikation.', + resourceForbidden: 'Sie haben keinen Zugriff auf diese Ressource.', ok: 'OK', submit: 'Absenden', cancel: 'Abbrechen', @@ -16,6 +17,9 @@ export const deAppTranslations = { delete: 'Löschen', close: 'Schliessen', datePlaceholder: 'JJJJ-MM-TT', + workgroup: { + title: 'Arbeitsgruppe', + }, menuBar: { assets: 'Assets', admin: 'Verwaltung', @@ -77,6 +81,7 @@ export const deAppTranslations = { languageItem: { None: 'keine', }, + workgroup: 'Arbeitsgruppe', resetSearch: 'Suche zurücksetzen', file: 'Datei', openFileInNewTab: '{{fileName}} in neuem Tab öffnen', @@ -232,4 +237,37 @@ export const deAppTranslations = { ' Damit wird sichergestellt, dass die Suche alle vorhandenen Assets miteinbezieht.', adminInstructionsSyncElasticAssetsStart: 'Synchronisation starten', }, + admin: { + users: 'Benutzer', + workgroups: 'Arbeitsgruppen', + name: 'Name', + role: 'Rolle', + actions: 'Aktionen', + email: 'E-Mail', + back: 'Zurück', + languages: { + de: 'Deutsch', + en: 'Englisch', + fr: 'Französisch', + it: 'Italienisch', + rm: 'Rätoromanisch', + }, + userPage: { + admin: 'Admin', + lang: 'Sprache', + addWorkgroups: 'Arbeitsgruppen hinzufügen', + more: 'weitere', + userAddError: 'Füge mindestens einen Benutzer hinzu', + }, + workgroupPage: { + name: 'Name', + isActive: 'Aktiv', + activate: 'Aktivieren', + deactivate: 'Deaktivieren', + create: 'Erstellen', + isDisabled: 'Deaktiviert', + chooseUsersText: 'Füge Benutzer hinzu, um sie zu verwalten', + addUsers: 'Benutzer hinzufügen', + }, + }, }; diff --git a/apps/client-asset-sg/src/app/i18n/en.ts b/apps/client-asset-sg/src/app/i18n/en.ts index f5731511..4846c2c2 100644 --- a/apps/client-asset-sg/src/app/i18n/en.ts +++ b/apps/client-asset-sg/src/app/i18n/en.ts @@ -4,6 +4,7 @@ export const enAppTranslations: AppTranslations = { logoSwissGeol: 'Logo Swissgeol Assets', welcomeTo: 'Welcome to', accessForbidden: 'You do not have access to this application.', + resourceForbidden: 'You do not have access to this resource.', ok: 'OK', submit: 'Submit', cancel: 'Cancel', @@ -18,6 +19,9 @@ export const enAppTranslations: AppTranslations = { delete: 'Delete', close: 'Close', datePlaceholder: 'YYYY-MM-DD', + workgroup: { + title: 'Workgroup', + }, menuBar: { assets: 'Assets', admin: 'Administration', @@ -78,6 +82,7 @@ export const enAppTranslations: AppTranslations = { languageItem: { None: 'none', }, + workgroup: 'Workgroup', resetSearch: 'Reset search', file: 'File', openFileInNewTab: 'Open {{fileName}} in new tab', @@ -233,4 +238,37 @@ export const enAppTranslations: AppTranslations = { ' This ensures that the search includes all existing assets.', adminInstructionsSyncElasticAssetsStart: 'Start synchronization', }, + admin: { + users: 'Users', + workgroups: 'Workgroups', + name: 'Name', + role: 'Role', + actions: 'Actions', + email: 'Email', + back: 'Back', + languages: { + de: 'German', + en: 'English', + fr: 'French', + it: 'Italian', + rm: 'Romansh', + }, + userPage: { + admin: 'Admin', + lang: 'Language', + addWorkgroups: 'Add workgroups', + more: 'more', + userAddError: 'Add at least one user', + }, + workgroupPage: { + name: 'Name', + isActive: 'Active', + activate: 'Activate', + deactivate: 'Deactivate', + create: 'Create', + isDisabled: 'Deactivated', + chooseUsersText: 'Add users to manage', + addUsers: 'Add users', + }, + }, }; diff --git a/apps/client-asset-sg/src/app/i18n/fr.ts b/apps/client-asset-sg/src/app/i18n/fr.ts index 40ceed6d..a7b2aeab 100644 --- a/apps/client-asset-sg/src/app/i18n/fr.ts +++ b/apps/client-asset-sg/src/app/i18n/fr.ts @@ -4,6 +4,7 @@ export const frAppTranslations: AppTranslations = { logoSwissGeol: 'Logo Swissgeol Assets', welcomeTo: 'Bienvenue sur', accessForbidden: "Vous n'avez pas accès à cette application.", + resourceForbidden: "Vous n'avez pas accès à cette ressource.", ok: 'OK', submit: 'Envoyer', cancel: 'Annuler', @@ -18,6 +19,9 @@ export const frAppTranslations: AppTranslations = { delete: 'Supprimer', close: 'Fermer', datePlaceholder: 'AAAA-MM-JJ', + workgroup: { + title: 'groupe de travail', + }, menuBar: { assets: 'Assets', admin: 'Administration', @@ -78,6 +82,7 @@ export const frAppTranslations: AppTranslations = { languageItem: { None: 'aucune', }, + workgroup: 'groupe de travail', resetSearch: 'Réinitialiser la recherche', file: 'Fichier', openFileInNewTab: 'Ouvrir {{fileName}} dans un nouvel onglet', @@ -234,4 +239,37 @@ export const frAppTranslations: AppTranslations = { " Cela permet de s'assurer que la recherche inclut tous les actifs existants.", adminInstructionsSyncElasticAssetsStart: 'Démarrer la synchronisation', }, + admin: { + users: 'Utilisateurs', + workgroups: 'Groupes de travail', + name: 'Nom', + role: 'Rôle', + actions: 'Actions', + email: 'E-mail', + back: 'Retour', + languages: { + de: 'Allemand', + en: 'Anglais', + fr: 'Français', + it: 'Italien', + rm: 'Romanche', + }, + userPage: { + admin: 'Admin', + lang: 'Langue', + addWorkgroups: 'Ajouter des groupes de travail', + more: 'en plus', + userAddError: 'Ajoute au moins un utilisateur', + }, + workgroupPage: { + name: 'Nom', + isActive: 'Actif', + create: 'Créer', + activate: 'Activer', + deactivate: 'Désactiver', + isDisabled: 'Désactivé', + chooseUsersText: 'Ajoutez des utilisateurs pour les gérer', + addUsers: 'Ajouter des utilisateurs', + }, + }, }; diff --git a/apps/client-asset-sg/src/app/i18n/it.ts b/apps/client-asset-sg/src/app/i18n/it.ts index 9f0105da..c0dc7e83 100644 --- a/apps/client-asset-sg/src/app/i18n/it.ts +++ b/apps/client-asset-sg/src/app/i18n/it.ts @@ -4,6 +4,7 @@ export const itAppTranslations: AppTranslations = { logoSwissGeol: 'Logo Swissgeol Assets', welcomeTo: 'Benvenuti su', accessForbidden: 'Non avete accesso a questa applicazione.', + resourceForbidden: 'IT Sie haben keinen Zugriff auf diese Ressource.', ok: 'OK', submit: 'IT Absenden', cancel: 'IT Abbrechen', @@ -18,6 +19,9 @@ export const itAppTranslations: AppTranslations = { delete: 'IT Löschen', close: 'IT Schliessen', datePlaceholder: 'AAAA-MM-GG', + workgroup: { + title: 'IT Arbeitsgruppe', + }, menuBar: { assets: 'IT Assets', admin: 'IT Verwaltung', @@ -78,6 +82,7 @@ export const itAppTranslations: AppTranslations = { languageItem: { None: 'IT keine', }, + workgroup: 'IT Arbeitsgruppe', resetSearch: 'IT Suche zurücksetzen', file: 'IT Datei', openFileInNewTab: 'IT {{fileName}} in neuem Tab öffnen', @@ -233,4 +238,37 @@ export const itAppTranslations: AppTranslations = { 'IT Damit wird sichergestellt, dass die Suche alle vorhandenen Assets miteinbezieht.', adminInstructionsSyncElasticAssetsStart: 'IT Synchronisation starten', }, + admin: { + users: 'IT Benutzer', + workgroups: 'IT Arbeitsgruppen', + name: 'IT Name', + role: 'IT Rolle', + actions: 'IT Aktionen', + email: 'IT E-Mail', + back: 'IT Zurück', + languages: { + de: 'IT Deutsch', + en: 'IT Englisch', + fr: 'IT Französisch', + it: 'IT Italienisch', + rm: 'IT Rätoromanisch', + }, + userPage: { + admin: 'IT Admin', + lang: 'IT Sprache', + addWorkgroups: 'IT Arbeitsgruppen hinzufügen', + more: 'IT weitere', + userAddError: 'IT Füge mindestens einen Benutzer hinzu', + }, + workgroupPage: { + name: 'IT Name', + isActive: 'IT Aktiv', + activate: 'IT Aktivieren', + deactivate: 'IT Deaktivieren', + create: 'IT Erstellen', + isDisabled: 'IT Deaktiviert', + chooseUsersText: 'IT Füge Nutzer hinzu, um sie zu verwalten', + addUsers: 'IT Benutzer hinzufügen', + }, + }, }; diff --git a/apps/client-asset-sg/src/app/i18n/rm.ts b/apps/client-asset-sg/src/app/i18n/rm.ts index 4f81129d..f4f0457b 100644 --- a/apps/client-asset-sg/src/app/i18n/rm.ts +++ b/apps/client-asset-sg/src/app/i18n/rm.ts @@ -4,6 +4,7 @@ export const rmAppTranslations: AppTranslations = { logoSwissGeol: 'Logo Swissgeol Assets', welcomeTo: 'RM Willkommen bei', accessForbidden: 'RM Sie haben keinen Zugriff auf diese Applikation.', + resourceForbidden: 'RM Sie haben keinen Zugriff auf diese Ressource.', ok: 'OK', submit: 'RM Absenden', cancel: 'RM Abbrechen', @@ -18,6 +19,9 @@ export const rmAppTranslations: AppTranslations = { delete: 'RM Löschen', close: 'RM Schliessen', datePlaceholder: 'AAAA-MM-GG', + workgroup: { + title: 'IT Arbeitsgruppe', + }, menuBar: { assets: 'RM Assets', admin: 'RM Verwaltung', @@ -78,6 +82,7 @@ export const rmAppTranslations: AppTranslations = { languageItem: { None: 'RM keine', }, + workgroup: 'RM Arbeitsgruppe', resetSearch: 'RM Suche zurücksetzen', file: 'RM Datei', openFileInNewTab: 'RM {{fileName}} in neuem Tab öffnen', @@ -233,4 +238,37 @@ export const rmAppTranslations: AppTranslations = { 'RM Damit wird sichergestellt, dass die Suche alle vorhandenen Assets miteinbezieht.', adminInstructionsSyncElasticAssetsStart: 'RM Synchronisation starten', }, + admin: { + users: 'RM Benutzer', + workgroups: 'RM Arbeitsgruppen', + name: 'RM Name', + role: 'RM Rolle', + actions: 'RM Aktionen', + email: 'RM E-Mail', + back: 'RM Zurück', + languages: { + de: 'RM Deutsch', + en: 'RM Englisch', + fr: 'RM Französisch', + it: 'RM Italienisch', + rm: 'RM Rätoromanisch', + }, + userPage: { + admin: 'RM Admin', + lang: 'RM Sprache', + addWorkgroups: 'RM Arbeitsgruppen hinzufügen', + more: 'RM weitere', + userAddError: 'RM Füge mindestens einen Benutzer hinzu', + }, + workgroupPage: { + name: 'RM Name', + isActive: 'RM Aktiv', + create: 'RM Erstellen', + activate: 'RM Aktivieren', + deactivate: 'RM Deaktivieren', + isDisabled: 'RM Deaktiviert', + chooseUsersText: 'RM Füge Nutzer hinzu, um sie zu verwalten', + addUsers: 'RM Benutzer hinzufügen', + }, + }, }; diff --git a/apps/client-asset-sg/src/app/state/app-shared-state.service.ts b/apps/client-asset-sg/src/app/state/app-shared-state.service.ts index 5fe81529..b56d7843 100644 --- a/apps/client-asset-sg/src/app/state/app-shared-state.service.ts +++ b/apps/client-asset-sg/src/app/state/app-shared-state.service.ts @@ -3,10 +3,11 @@ import { Injectable } from '@angular/core'; import { ApiError, httpErrorResponseOrUnknownError } from '@asset-sg/client-shared'; import { OE, ORD, decodeError } from '@asset-sg/core'; import { ReferenceData } from '@asset-sg/shared'; +import { SimpleWorkgroup } from '@asset-sg/shared/v2'; import * as RD from '@devexperts/remote-data-ts'; import * as E from 'fp-ts/Either'; import { flow } from 'fp-ts/function'; -import { map, startWith } from 'rxjs'; +import { map, Observable, startWith } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AppSharedStateService { @@ -22,4 +23,8 @@ export class AppSharedStateService { startWith(RD.pending) ); } + + public loadWorkgroups(): Observable { + return this._httpClient.get('/api/workgroups?simple'); + } } diff --git a/apps/client-asset-sg/src/app/state/app-shared.reducer.ts b/apps/client-asset-sg/src/app/state/app-shared.reducer.ts index ee243c5d..c1433bd9 100644 --- a/apps/client-asset-sg/src/app/state/app-shared.reducer.ts +++ b/apps/client-asset-sg/src/app/state/app-shared.reducer.ts @@ -8,6 +8,7 @@ import * as R from 'fp-ts/Record'; const initialState: AppSharedState = { rdUserProfile: RD.initial, rdReferenceData: RD.initial, + workgroups: [], lang: 'de', }; @@ -21,6 +22,7 @@ export const appSharedStateReducer = createReducer( appSharedStateActions.loadReferenceDataResult, (state, rdReferenceData): AppSharedState => ({ ...state, rdReferenceData }) ), + on(appSharedStateActions.loadWorkgroupsResult, (state, { workgroups }): AppSharedState => ({ ...state, workgroups })), on(appSharedStateActions.logout, (): AppSharedState => initialState), on(appSharedStateActions.setLang, (state, { lang }): AppSharedState => ({ ...state, lang })), on( diff --git a/apps/client-asset-sg/src/app/state/app.effects.ts b/apps/client-asset-sg/src/app/state/app.effects.ts index 34045979..0c9c0738 100644 --- a/apps/client-asset-sg/src/app/state/app.effects.ts +++ b/apps/client-asset-sg/src/app/state/app.effects.ts @@ -1,16 +1,16 @@ -import { Injectable, inject } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { NavigationEnd, Router, RouterStateSnapshot } from '@angular/router'; import { AuthService } from '@asset-sg/auth'; import { appSharedStateActions, fromAppShared } from '@asset-sg/client-shared'; import { ORD } from '@asset-sg/core'; -import { Lang, eqLangRight } from '@asset-sg/shared'; +import { eqLangRight, Lang } from '@asset-sg/shared'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { ROUTER_NAVIGATION, RouterNavigationAction } from '@ngrx/router-store'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import * as E from 'fp-ts/Either'; -import { combineLatest, distinctUntilChanged, filter, map, merge, switchMap, take } from 'rxjs'; +import { combineLatest, distinctUntilChanged, filter, map, switchMap, take } from 'rxjs'; import { AppSharedStateService } from './app-shared-state.service'; import { AppState } from './app-state'; @@ -40,19 +40,10 @@ export class AppSharedStateEffects { } }); - merge( - this.actions$.pipe( - ofType>(ROUTER_NAVIGATION), - filter((a) => !a.payload.routerState.url.match(/^\/\w\w\/a\//)), - take(1) - ), - this.actions$.pipe(ofType(appSharedStateActions.logout)) - ) - .pipe(untilDestroyed(this)) - .subscribe(() => { - this.store.dispatch(appSharedStateActions.loadUserProfile()); - this.store.dispatch(appSharedStateActions.loadReferenceData()); - }); + this.actions$.pipe(ofType(appSharedStateActions.logout), untilDestroyed(this)).subscribe(() => { + this.store.dispatch(appSharedStateActions.loadUserProfile()); + this.store.dispatch(appSharedStateActions.loadReferenceData()); + }); this.actions$ .pipe( @@ -92,4 +83,12 @@ export class AppSharedStateEffects { map(appSharedStateActions.loadUserProfileResult) ) ); + + loadWorkgroups$ = createEffect(() => + this.actions$.pipe( + ofType(appSharedStateActions.loadWorkgroups), + switchMap(() => this.appSharedStateService.loadWorkgroups()), + map((workgroups) => appSharedStateActions.loadWorkgroupsResult({ workgroups })) + ) + ); } diff --git a/apps/client-asset-sg/src/environments/environment.ts b/apps/client-asset-sg/src/environments/environment.ts index 40c57800..a1267e9c 100644 --- a/apps/client-asset-sg/src/environments/environment.ts +++ b/apps/client-asset-sg/src/environments/environment.ts @@ -1,5 +1,5 @@ import { CompileTimeEnvironment } from './environment-type'; export const environment: CompileTimeEnvironment = { - ngrxStoreLoggerEnabled: true, + ngrxStoreLoggerEnabled: false, }; diff --git a/apps/server-asset-sg/prisma/migrations/20230111143745_init/migration.sql b/apps/server-asset-sg/prisma/migrations/20230111143745_init/migration.sql index f0ad43be..fabb6bf7 100644 --- a/apps/server-asset-sg/prisma/migrations/20230111143745_init/migration.sql +++ b/apps/server-asset-sg/prisma/migrations/20230111143745_init/migration.sql @@ -1,3 +1,5 @@ +DROP SCHEMA IF EXISTS "auth" CASCADE; + -- CreateSchema CREATE SCHEMA IF NOT EXISTS "auth"; diff --git a/apps/server-asset-sg/prisma/migrations/20240627070547_add_workgroups/migration.sql b/apps/server-asset-sg/prisma/migrations/20240627070547_add_workgroups/migration.sql new file mode 100644 index 00000000..8bee5bfc --- /dev/null +++ b/apps/server-asset-sg/prisma/migrations/20240627070547_add_workgroups/migration.sql @@ -0,0 +1,80 @@ +/* + Warnings: + + - You are about to drop the column `role` on the `asset_user` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('viewer', 'editor', 'master-editor'); + +-- AlterTable +ALTER TABLE "asset" ADD COLUMN "workgroup_id" INTEGER; + + +-- CreateTable +CREATE TABLE "workgroup" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "disabled_at" TIMESTAMPTZ(6), + + CONSTRAINT "workgroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workgroups_on_users" ( + "workgroup_id" INTEGER NOT NULL, + "user_id" UUID NOT NULL, + "role" "Role" NOT NULL, + + CONSTRAINT "workgroups_on_users_pkey" PRIMARY KEY ("workgroup_id","user_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "workgroup_name_key" ON "workgroup"("name"); + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_workgroup_id_fkey" FOREIGN KEY ("workgroup_id") REFERENCES "workgroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workgroups_on_users" ADD CONSTRAINT "workgroups_on_users_workgroup_id_fkey" FOREIGN KEY ("workgroup_id") REFERENCES "workgroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workgroups_on_users" ADD CONSTRAINT "workgroups_on_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "asset_user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +INSERT INTO "workgroup" ("name", "created_at") +VALUES ('Swisstopo', NOW()); + +DO +$$ + DECLARE +swisstopo_id INTEGER; +BEGIN +SELECT "id" INTO swisstopo_id FROM "workgroup" WHERE name = 'Swisstopo'; + +-- Update all assets to be assigned to the "Swisstopo" workgroup +UPDATE "asset" SET "workgroup_id" = swisstopo_id; + +-- Assign all users to the "Swisstopo" workgroup with role "VIEWER" +INSERT INTO "workgroups_on_users" ("workgroup_id", "user_id", "role") +SELECT + swisstopo_id, + "id", + CASE + WHEN "asset_user"."role" = 'admin' THEN 'master-editor'::"Role" + WHEN "asset_user"."role" = 'editor' THEN 'editor'::"Role" + WHEN "asset_user"."role" = 'viewer' THEN 'viewer'::"Role" + WHEN "asset_user"."role" = 'master-editor' THEN 'master-editor'::"Role" + ELSE 'viewer'::"Role" + END + FROM "asset_user"; +END +$$; + +-- AlterTable +ALTER TABLE "asset_user" ADD COLUMN "is_admin" BOOLEAN NOT NULL DEFAULT false; + +UPDATE "asset_user" SET "is_admin" = true WHERE role = 'admin'; + +ALTER TABLE "asset_user" ALTER COLUMN "is_admin" SET NOT NULL; +ALTER TABLE "asset_user" DROP COLUMN "role"; diff --git a/apps/server-asset-sg/prisma/schema.prisma b/apps/server-asset-sg/prisma/schema.prisma index 6256aad1..040d667b 100644 --- a/apps/server-asset-sg/prisma/schema.prisma +++ b/apps/server-asset-sg/prisma/schema.prisma @@ -59,6 +59,9 @@ model Asset { studyTraces StudyTrace[] typeNatRels TypeNatRel[] + workgroup Workgroup @relation(fields: [workgroupId], references: [id]) + workgroupId Int @map("workgroup_id") + siblingXAssets AssetXAssetY[] @relation("sibling_x_asset") siblingYAssets AssetXAssetY[] @relation("sibling_y_asset") @@ -636,11 +639,12 @@ model StatusWorkItem { model AssetUser { id String @id @db.Uuid - role String email String lang String oidcId String AssetUserFavourite AssetUserFavourite[] + workgroups WorkgroupsOnUsers[] + isAdmin Boolean @default(false) @map("is_admin") @@map("asset_user") } @@ -658,6 +662,34 @@ model AssetUserFavourite { @@map("asset_user_favourite") } +model Workgroup { + id Int @id @default(autoincrement()) + name String @unique + created_at DateTime @db.Timestamptz(6) + disabled_at DateTime? @db.Timestamptz(6) + users WorkgroupsOnUsers[] + assets Asset[] + + @@map("workgroup") +} + +model WorkgroupsOnUsers { + workgroup Workgroup @relation(fields: [workgroupId], references: [id]) + workgroupId Int @map("workgroup_id") + user AssetUser @relation(fields: [userId], references: [id]) + userId String @map("user_id") @db.Uuid + role Role + + @@id([workgroupId, userId]) + @@map("workgroups_on_users") +} + +enum Role { + Viewer @map("viewer") + Editor @map("editor") + MasterEditor @map("master-editor") +} + view AllStudy { assetId Int @map("asset_id") asset Asset @relation(fields: [assetId], references: [assetId]) diff --git a/apps/server-asset-sg/src/app.controller.ts b/apps/server-asset-sg/src/app.controller.ts index ffc66edd..a02b1366 100644 --- a/apps/server-asset-sg/src/app.controller.ts +++ b/apps/server-asset-sg/src/app.controller.ts @@ -1,54 +1,22 @@ -import { DT, decodeError, unknownToError } from '@asset-sg/core'; -import { AssetByTitle, PatchAsset, PatchContact, isEditor } from '@asset-sg/shared'; -import { User as AssetUser } from '@asset-sg/shared'; -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpException, - HttpStatus, - Param, - ParseIntPipe, - Patch, - Post, - Put, - Query, - Redirect, - Req, - UploadedFile, - UseInterceptors, - ValidationPipe, -} from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { plainToInstance } from 'class-transformer'; +import { unknownToError } from '@asset-sg/core'; +import { AssetByTitle } from '@asset-sg/shared'; +import { User } from '@asset-sg/shared/v2'; +import { Controller, Get, HttpException, Query } from '@nestjs/common'; +import { sequenceS } from 'fp-ts/Apply'; +import * as A from 'fp-ts/Array'; import * as E from 'fp-ts/Either'; -import { pipe } from 'fp-ts/function'; -import * as TE from 'fp-ts/TaskEither'; -import { RequireRole } from '@/core/decorators/require-role.decorator'; -import { AssetEditRepo } from '@/features/asset-old/asset-edit.repo'; -import { AssetEditDetail, AssetEditService } from '@/features/asset-old/asset-edit.service'; +import { flow, Lazy, pipe } from 'fp-ts/function'; +import * as RR from 'fp-ts/ReadonlyRecord'; +import * as TE from 'fp-ts/TaskEither'; +import { Authorize } from '@/core/decorators/authorize.decorator'; +import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { PrismaService } from '@/core/prisma.service'; import { AssetSearchService } from '@/features/assets/search/asset-search.service'; -import { Contact, ContactData, ContactDataBoundary, ContactId } from '@/features/contacts/contact.model'; -import { ContactRepo } from '@/features/contacts/contact.repo'; -import { ContactsController } from '@/features/contacts/contacts.controller'; -import { Role, User, UserDataBoundary, UserId } from '@/features/users/user.model'; -import { UserRepo } from '@/features/users/user.repo'; -import { UsersController } from '@/features/users/users.controller'; -import { JwtRequest } from '@/models/jwt-request'; -import { permissionDeniedError } from '@/utils/errors'; @Controller('/') export class AppController { - constructor( - private readonly assetEditRepo: AssetEditRepo, - private readonly assetEditService: AssetEditService, - private readonly userRepo: UserRepo, - private readonly contactRepo: ContactRepo, - private readonly assetSearchService: AssetSearchService - ) {} + constructor(private readonly assetSearchService: AssetSearchService, private readonly prismaService: PrismaService) {} @Get('/oauth-config/config') getConfig() { @@ -62,83 +30,11 @@ export class AppController { }; } - /** - * @deprecated - */ - @Get('/user') - @Redirect('users/current', 301) - getUser() { - // deprecated - } - - /** - * @deprecated - */ - @Get('/user/favourite') - @Redirect('../users/current/favorites', 301) - async getFavourites() { - // deprecated - } - - /** - * @deprecated - */ - @Get('/admin/user') - @Redirect('../users', 301) - getUsers() { - // deprecated - } - - /** - * @deprecated - */ - @Patch('/admin/user/:id') - @RequireRole(Role.Admin) - updateUser( - @Param('id') id: UserId, - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - data: UserDataBoundary - ): Promise { - return new UsersController(this.userRepo).update(id, data); - } - - /** - * @deprecated - */ - @Delete('/admin/user/:id') - @RequireRole(Role.Admin) - @HttpCode(HttpStatus.NO_CONTENT) - async deleteUser(@Param('id') id: UserId): Promise { - await new UsersController(this.userRepo).delete(id); - } - - /** - * @deprecated - */ - @Put('/contact-edit') - @RequireRole(Role.Editor) - @HttpCode(HttpStatus.CREATED) - async createContact(@Body() patch: PatchContact) { - const data: ContactData = patch; - const boundary = plainToInstance(ContactDataBoundary, data); - return new ContactsController(this.contactRepo).create(boundary); - } - - /** - * @deprecated - */ - @Patch('/contact-edit/:id') - @RequireRole(Role.Editor) - updateContact(@Param('id', ParseIntPipe) id: ContactId, patch: PatchContact): Promise { - const data: ContactData = patch; - const boundary = plainToInstance(ContactDataBoundary, data); - return new ContactsController(this.contactRepo).update(id, boundary); - } - /** * @deprecated */ @Get('/asset-edit/search') + @Authorize.User() async searchAssetsByTitle(@Query('title') title: string): Promise { try { return await this.assetSearchService.searchByTitle(title); @@ -147,133 +43,62 @@ export class AppController { } } - /** - * @deprecated - */ - @Get('/asset-edit/:assetId') - async getAsset(@Param('assetId') assetId: string): Promise { - const id = parseInt(assetId); - if (isNaN(id)) { - throw new HttpException('Resource not found', 404); - } - const asset = await this.assetEditRepo.find(id); - if (asset === null) { - throw new HttpException('Resource not found', 404); - } - return AssetEditDetail.encode(asset); - } - - /** - * @deprecated - */ - @Put('/asset-edit') - async createAsset(@Req() req: JwtRequest, @Body() patchAsset: PatchAsset) { - const e = await pipe( - TE.of(req.user as unknown as AssetUser), - TE.filterOrElseW( - (user) => isEditor(user), - () => permissionDeniedError('Not an editor') - ), - TE.bindTo('user'), - TE.bindW('patchAsset', () => TE.fromEither(pipe(PatchAsset.decode(patchAsset), E.mapLeft(decodeError)))), - TE.chainW(({ patchAsset, user }) => this.assetEditService.createAsset(user, patchAsset)) - )(); - if (E.isLeft(e)) { - console.error(e.left); - // if (e.left._tag === 'decodeError') { - // throw new HttpException(e.left.message, 400); - // } - throw new HttpException(e.left.message, 500); - } - return e.right; - } - - /** - * @deprecated - */ - @Patch('/asset-edit/:assetId') - async updateAsset(@Req() req: JwtRequest, @Param('assetId') id: string, @Body() patchAsset: PatchAsset) { - const e = await pipe( - TE.of(req.user as unknown as AssetUser), - TE.filterOrElseW( - (user) => isEditor(user), - () => permissionDeniedError('Not an editor') - ), - TE.bindTo('user'), - TE.bindW('id', () => TE.fromEither(pipe(DT.IntFromString.decode(id), E.mapLeft(decodeError)))), - TE.bindW('patchAsset', () => TE.fromEither(pipe(PatchAsset.decode(patchAsset), E.mapLeft(decodeError)))), - TE.chainW(({ id, patchAsset, user }) => this.assetEditService.updateAsset(user, id, patchAsset)) - )(); + @Get('/reference-data') + @Authorize.User() + async getReferenceData(@CurrentUser() user: User) { + const e = await getReferenceData(user, this.prismaService)(); if (E.isLeft(e)) { console.error(e.left); - // if (e.left._tag === 'decodeError') { - // throw new HttpException(e.left.message, 400); - // } throw new HttpException(e.left.message, 500); } return e.right; } +} - /** - * @deprecated - */ - @Post('/asset-edit/:assetId/file') - @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 250 * 1024 * 1024 } })) - async uploadAssetFile( - @Req() req: JwtRequest, - @Param('assetId') id: string, - @UploadedFile() file: { originalname: string; buffer: Buffer; size: number; mimetype: string } - ) { - const e = await pipe( - TE.of(req.user as unknown as AssetUser), - TE.filterOrElseW( - (user) => isEditor(user), - () => permissionDeniedError('Not an editor') - ), - TE.bindTo('user'), - TE.bindW('id', () => TE.fromEither(pipe(DT.IntFromString.decode(id), E.mapLeft(decodeError)))), - TE.chainW(({ user, id }) => - this.assetEditService.uploadFile(user, id, { - name: file.originalname, - buffer: file.buffer, - size: file.size, - mimetype: file.mimetype, - }) +const getReferenceData = (user: User, prismaService: PrismaService) => { + const qt = (f: Lazy>, key: K, newKey: string) => + pipe( + TE.tryCatch(f, unknownToError), + TE.map( + flow( + A.map(({ [key]: _key, ...rest }) => [_key as string, { [newKey]: _key, ...rest }] as const), + RR.fromEntries + ) ) - )(); - if (E.isLeft(e)) { - console.error(e.left); - // if (e.left._tag === 'decodeError') { - // throw new HttpException(e.left.message, 400); - // } - throw new HttpException(e.left.message, 500); - } - return e.right; - } - - /** - * @deprecated - */ - @Delete('/asset-edit/:assetId/file/:fileId') - async deleteAssetFile(@Req() req: JwtRequest, @Param('assetId') assetId: string, @Param('fileId') fileId: string) { - const e = await pipe( - TE.of(req.user as unknown as AssetUser), - TE.filterOrElseW( - (user) => isEditor(user), - () => permissionDeniedError('Not an editor') - ), - TE.bindTo('user'), - TE.bindW('assetId', () => TE.fromEither(pipe(DT.IntFromString.decode(assetId), E.mapLeft(decodeError)))), - TE.bindW('fileId', () => TE.fromEither(pipe(DT.IntFromString.decode(fileId), E.mapLeft(decodeError)))), - TE.chainW(({ user, assetId, fileId }) => this.assetEditService.deleteFile(user, assetId, fileId)) - )(); - if (E.isLeft(e)) { - console.error(e.left); - // if (e.left._tag === 'decodeError') { - // throw new HttpException(e.left.message, 400); - // } - throw new HttpException(e.left.message, 500); - } - return e.right; - } -} + ); + + const queries = { + // These records are all static (i.e. never change) and are shared across all assets. + assetFormatItems: qt(() => prismaService.assetFormatItem.findMany(), 'assetFormatItemCode', 'code'), + assetKindItems: qt(() => prismaService.assetKindItem.findMany(), 'assetKindItemCode', 'code'), + autoCatLabelItems: qt(() => prismaService.autoCatLabelItem.findMany(), 'autoCatLabelItemCode', 'code'), + autoObjectCatItems: qt(() => prismaService.autoObjectCatItem.findMany(), 'autoObjectCatItemCode', 'code'), + contactKindItems: qt(() => prismaService.contactKindItem.findMany(), 'contactKindItemCode', 'code'), + geomQualityItems: qt(() => prismaService.geomQualityItem.findMany(), 'geomQualityItemCode', 'code'), + languageItems: qt(() => prismaService.languageItem.findMany(), 'languageItemCode', 'code'), + legalDocItems: qt(() => prismaService.legalDocItem.findMany(), 'legalDocItemCode', 'code'), + manCatLabelItems: qt(() => prismaService.manCatLabelItem.findMany(), 'manCatLabelItemCode', 'code'), + natRelItems: qt(() => prismaService.natRelItem.findMany(), 'natRelItemCode', 'code'), + pubChannelItems: qt(() => prismaService.pubChannelItem.findMany(), 'pubChannelItemCode', 'code'), + statusAssetUseItems: qt(() => prismaService.statusAssetUseItem.findMany(), 'statusAssetUseItemCode', 'code'), + statusWorkItems: qt(() => prismaService.statusWorkItem.findMany(), 'statusWorkItemCode', 'code'), + + // Include only the contacts which are assigned to at least one asset to which the user has access. + contacts: qt( + () => + user.isAdmin + ? prismaService.contact.findMany() + : prismaService.contact.findMany({ + where: { + assetContacts: { + some: { asset: { workgroupId: { in: [...user.roles.keys()] } } }, + }, + }, + }), + 'contactId', + 'id' + ), + }; + + return pipe(queries, sequenceS(TE.ApplicativeSeq)); +}; diff --git a/apps/server-asset-sg/src/app.module.ts b/apps/server-asset-sg/src/app.module.ts index 28307e9f..a52e1fa3 100644 --- a/apps/server-asset-sg/src/app.module.ts +++ b/apps/server-asset-sg/src/app.module.ts @@ -6,13 +6,12 @@ import { ScheduleModule } from '@nestjs/schedule'; import { AppController } from '@/app.controller'; import { provideElasticsearch } from '@/core/elasticsearch'; -import { RoleGuard } from '@/core/guards/role.guard'; +import { AuthorizationGuard } from '@/core/guards/authorization-guard.service'; import { JwtMiddleware } from '@/core/middleware/jwt.middleware'; import { PrismaService } from '@/core/prisma.service'; -import { AssetEditRepo } from '@/features/asset-old/asset-edit.repo'; -import { AssetEditService } from '@/features/asset-old/asset-edit.service'; -import { AssetController } from '@/features/asset-old/asset.controller'; -import { AssetService } from '@/features/asset-old/asset.service'; +import { AssetEditController } from '@/features/asset-edit/asset-edit.controller'; +import { AssetEditRepo } from '@/features/asset-edit/asset-edit.repo'; +import { AssetEditService } from '@/features/asset-edit/asset-edit.service'; import { AssetInfoRepo } from '@/features/assets/asset-info.repo'; import { AssetRepo } from '@/features/assets/asset.repo'; import { AssetsController } from '@/features/assets/assets.controller'; @@ -23,42 +22,47 @@ import { ContactRepo } from '@/features/contacts/contact.repo'; import { ContactsController } from '@/features/contacts/contacts.controller'; import { FavoriteRepo } from '@/features/favorites/favorite.repo'; import { FavoritesController } from '@/features/favorites/favorites.controller'; +import { FilesController } from '@/features/files/files.controller'; import { OcrController } from '@/features/ocr/ocr.controller'; import { StudiesController } from '@/features/studies/studies.controller'; import { StudyRepo } from '@/features/studies/study.repo'; import { UserRepo } from '@/features/users/user.repo'; import { UsersController } from '@/features/users/users.controller'; +import { WorkgroupRepo } from '@/features/workgroups/workgroup.repo'; +import { WorkgroupsController } from '@/features/workgroups/workgroups.controller'; @Module({ controllers: [ AppController, - UsersController, - FavoritesController, - AssetSyncController, + AssetEditController, AssetSearchController, + AssetSyncController, AssetsController, - AssetController, - StudiesController, ContactsController, + FavoritesController, + FilesController, OcrController, + StudiesController, + UsersController, + WorkgroupsController, ], imports: [HttpModule, ScheduleModule.forRoot(), CacheModule.register()], providers: [ provideElasticsearch, - PrismaService, - AssetRepo, - AssetInfoRepo, - AssetService, AssetEditRepo, + AssetEditService, + AssetInfoRepo, + AssetRepo, + AssetSearchService, ContactRepo, FavoriteRepo, - UserRepo, + PrismaService, StudyRepo, - AssetEditService, - AssetSearchService, + UserRepo, + WorkgroupRepo, { provide: APP_GUARD, - useClass: RoleGuard, + useClass: AuthorizationGuard, }, ], }) diff --git a/apps/server-asset-sg/src/core/authorize.ts b/apps/server-asset-sg/src/core/authorize.ts new file mode 100644 index 00000000..fd7f2a5d --- /dev/null +++ b/apps/server-asset-sg/src/core/authorize.ts @@ -0,0 +1,34 @@ +import { User } from '@asset-sg/shared/v2'; +import { Policy } from '@asset-sg/shared/v2'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { Class } from 'type-fest'; + +export const authorize = (policy: Class>, currentUser: User): Authorize => { + return new Authorize(new policy(currentUser)); +}; + +class Authorize { + constructor(private readonly policy: Policy) {} + + canShow(value: T): void { + check(this.policy.canDoEverything() || this.policy.canShow(value)); + } + + canCreate(): void { + check(this.policy.canDoEverything() || this.policy.canCreate()); + } + + canUpdate(value: T): void { + check(this.policy.canDoEverything() || this.policy.canUpdate(value)); + } + + canDelete(value: T): void { + check(this.policy.canDoEverything() || this.policy.canDelete(value)); + } +} + +const check = (condition: boolean): void => { + if (!condition) { + throw new HttpException('Not authorized to access this resource', HttpStatus.FORBIDDEN); + } +}; diff --git a/apps/server-asset-sg/src/core/decorators/authorize.decorator.ts b/apps/server-asset-sg/src/core/decorators/authorize.decorator.ts new file mode 100644 index 00000000..858c75ab --- /dev/null +++ b/apps/server-asset-sg/src/core/decorators/authorize.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from '@nestjs/common'; + +export interface UserOnlyMetadata { + action: 'user-only'; +} + +export interface AdminOnlyMetadata { + action: 'admin-only'; +} + +export type AuthorizationMetadata = UserOnlyMetadata | AdminOnlyMetadata; + +export const Authorize = { + User: () => SetMetadata('authorization', { action: 'user-only' } as UserOnlyMetadata), + Admin: () => SetMetadata('authorization', { action: 'admin-only' } as AdminOnlyMetadata), +}; diff --git a/apps/server-asset-sg/src/core/decorators/authorized.decorator.ts b/apps/server-asset-sg/src/core/decorators/authorized.decorator.ts new file mode 100644 index 00000000..8e6aa711 --- /dev/null +++ b/apps/server-asset-sg/src/core/decorators/authorized.decorator.ts @@ -0,0 +1,14 @@ +import { Policy } from '@asset-sg/shared/v2'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthorizedRequest } from '@/models/jwt-request'; + +export const Authorized = { + Record: createParamDecorator((_param, ctx: ExecutionContext): unknown => { + const request = ctx.switchToHttp().getRequest() as AuthorizedRequest; + return request.authorized?.record ?? null; + }), + Policy: createParamDecorator((_param, ctx: ExecutionContext): Policy => { + const request = ctx.switchToHttp().getRequest() as AuthorizedRequest; + return request.authorized?.policy ?? null; + }), +}; diff --git a/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts b/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts index 6acc5283..38538239 100644 --- a/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts +++ b/apps/server-asset-sg/src/core/decorators/current-user.decorator.ts @@ -1,6 +1,6 @@ +import { User } from '@asset-sg/shared/v2'; import { ExecutionContext, createParamDecorator } from '@nestjs/common'; -import { User } from '@/features/users/user.model'; import { JwtRequest } from '@/models/jwt-request'; /** diff --git a/apps/server-asset-sg/src/core/decorators/parse.decorator.ts b/apps/server-asset-sg/src/core/decorators/parse.decorator.ts new file mode 100644 index 00000000..81564e5b --- /dev/null +++ b/apps/server-asset-sg/src/core/decorators/parse.decorator.ts @@ -0,0 +1,40 @@ +import { decodeError } from '@asset-sg/core'; +import { createParamDecorator, ExecutionContext, HttpException, HttpStatus, ValidationPipe } from '@nestjs/common'; +import * as E from 'fp-ts/Either'; +import * as D from 'io-ts/Decoder'; +import { Class } from 'type-fest'; +import { JwtRequest } from '@/models/jwt-request'; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type SchemaType = D.Decoder | Class; + +const validationPipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }); + +/** + * Parses and validates the request body using the given schema type. + * + * The schema type can be either a class using `class-validator` and `class-transformer`, + * or a decoder of `io-ts`. + * + * @example + * show(@Transform(MyValueSchema) myValue: MyValue) { + * console.log(`My parsed and validated value is ${myValue}.`); + * } + */ +export const ParseBody = createParamDecorator(async (dataType: SchemaType, context: ExecutionContext) => { + const request = context.switchToHttp().getRequest() as JwtRequest; + if (dataType instanceof Function) { + // It's a class transformer. + return validationPipe.transform(request.body, { type: 'body', metatype: dataType }); + } else { + // It's a decoder. + const data = (dataType as D.Decoder).decode(request.body); + if (E.isLeft(data)) { + throw new HttpException( + `invalid request body: ${decodeError(data.left).message}`, + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + return data.right as object; + } +}); diff --git a/apps/server-asset-sg/src/core/decorators/require-role.decorator.ts b/apps/server-asset-sg/src/core/decorators/require-role.decorator.ts deleted file mode 100644 index 194474bc..00000000 --- a/apps/server-asset-sg/src/core/decorators/require-role.decorator.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Reflector } from '@nestjs/core'; - -import { Role } from '@/features/users/user.model'; - -/** - * A decorator that guards NestJS routes by requiring an authenticated user - * with a specific minimal access role to be present. - * - * @example```ts - * @Post() - * @RequireRole(Role.Editor) - * async showRoute() { - * console.log('The current user is at least an editor.'); - * } - * ``` - */ -export const RequireRole = Reflector.createDecorator(); diff --git a/apps/server-asset-sg/src/core/decorators/use-policy.decorator.ts b/apps/server-asset-sg/src/core/decorators/use-policy.decorator.ts new file mode 100644 index 00000000..9c83379e --- /dev/null +++ b/apps/server-asset-sg/src/core/decorators/use-policy.decorator.ts @@ -0,0 +1,6 @@ +import { Policy } from '@asset-sg/shared/v2'; +import { Reflector } from '@nestjs/core'; + +import { Class } from 'type-fest'; + +export const UsePolicy = Reflector.createDecorator>>(); diff --git a/apps/server-asset-sg/src/core/decorators/use-repo.decorator.ts b/apps/server-asset-sg/src/core/decorators/use-repo.decorator.ts new file mode 100644 index 00000000..e28d8187 --- /dev/null +++ b/apps/server-asset-sg/src/core/decorators/use-repo.decorator.ts @@ -0,0 +1,6 @@ +import { Reflector } from '@nestjs/core'; + +import { Class } from 'type-fest'; +import { FindRepo, ReadRepo } from '@/core/repo'; + +export const UseRepo = Reflector.createDecorator>>(); diff --git a/apps/server-asset-sg/src/core/guards/authorization-guard.service.ts b/apps/server-asset-sg/src/core/guards/authorization-guard.service.ts new file mode 100644 index 00000000..0742cd83 --- /dev/null +++ b/apps/server-asset-sg/src/core/guards/authorization-guard.service.ts @@ -0,0 +1,36 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthorizationMetadata } from '@/core/decorators/authorize.decorator'; +import { JwtRequest } from '@/models/jwt-request'; + +@Injectable() +export class AuthorizationGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + async canActivate(context: ExecutionContext): Promise { + const auth = this.reflector.get('authorization', context.getHandler()); + if (auth == null) { + return true; + } + const request = context.switchToHttp().getRequest() as JwtRequest; + if (request.user == null) { + return false; + } + switch (auth.action) { + case 'user-only': + return this.authorizeUserOnly(context); + case 'admin-only': + return this.authorizeAdminOnly(context); + } + } + + private authorizeUserOnly(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() as JwtRequest; + return request.user != null; + } + + private authorizeAdminOnly(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() as JwtRequest; + return request.user.isAdmin ?? false; + } +} diff --git a/apps/server-asset-sg/src/core/guards/role.guard.ts b/apps/server-asset-sg/src/core/guards/role.guard.ts deleted file mode 100644 index 5290c8a8..00000000 --- a/apps/server-asset-sg/src/core/guards/role.guard.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -import { RequireRole } from '@/core/decorators/require-role.decorator'; -import { getRoleIndex } from '@/features/users/user.model'; -import { JwtRequest } from '@/models/jwt-request'; - -@Injectable() -export class RoleGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean | Promise { - const role = this.reflector.get(RequireRole, context.getHandler()); - if (role == null) { - return true; - } - const request = context.switchToHttp().getRequest() as JwtRequest; - return getRoleIndex(request.user.role) >= getRoleIndex(role); - } -} diff --git a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts index 383c8474..89582cc3 100644 --- a/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts +++ b/apps/server-asset-sg/src/core/middleware/jwt.middleware.ts @@ -1,6 +1,8 @@ +import { User } from '@asset-sg/shared/v2'; import { environment } from '@environment'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { HttpException, Inject, Injectable, NestMiddleware } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import axios from 'axios'; import { Cache } from 'cache-manager'; import { NextFunction, Request, Response } from 'express'; @@ -11,7 +13,6 @@ import * as jwt from 'jsonwebtoken'; import { Jwt, JwtPayload } from 'jsonwebtoken'; import jwkToPem from 'jwk-to-pem'; -import { Role, User } from '@/features/users/user.model'; import { UserRepo } from '@/features/users/user.repo'; import { JwtRequest } from '@/models/jwt-request'; @@ -61,7 +62,7 @@ export class JwtMiddleware implements NestMiddleware { await this.initializeRequest(req, result.right.accessToken, result.right.jwtPayload as JwtPayload); next(); } else { - res.status(403).json({ error: result.left.message }); + res.status(403).json({ error: 'not authorized by eIAM' }); } } @@ -193,7 +194,26 @@ export class JwtMiddleware implements NestMiddleware { if (email == null || !/^.+@.+\..+$/.test(email)) { throw new HttpException('invalid JWT payload: username does not contain an email', 401); } - return await this.userRepo.create({ oidcId, email, role: Role.Viewer, lang: 'de' }); + try { + return await this.userRepo.create({ + oidcId, + email, + lang: 'de', + isAdmin: false, + roles: new Map(), + }); + } catch (e) { + // If two requests of the same user overlap, it is possible that the user creation collides. + // If this happens, we load the user that has already been created by someone else from the DB. + if (!(e instanceof Prisma.PrismaClientKnownRequestError) || e.code !== 'P2002') { + throw e; + } + const user = await this.userRepo.find(oidcId); + if (user == null) { + throw e; + } + return user; + } } } diff --git a/apps/server-asset-sg/src/core/repo.ts b/apps/server-asset-sg/src/core/repo.ts index 67e7a53b..17f2b858 100644 --- a/apps/server-asset-sg/src/core/repo.ts +++ b/apps/server-asset-sg/src/core/repo.ts @@ -1,13 +1,19 @@ export type Repo = ReadRepo & MutateRepo; -export interface ReadRepo { +export interface FindRepo { find(id: TId): Promise; +} + +export interface ListRepo { list(options?: RepoListOptions): Promise; } -export type MutateRepo = CreateRepo & - UpdateRepo & - DeleteRepo; +export interface ReadRepo extends FindRepo, ListRepo {} + +export interface MutateRepo + extends CreateRepo, + UpdateRepo, + DeleteRepo {} export interface CreateRepo { create(data: TData): Promise; diff --git a/apps/server-asset-sg/src/features/asset-edit/asset-edit.controller.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.controller.ts new file mode 100644 index 00000000..650069dd --- /dev/null +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.controller.ts @@ -0,0 +1,84 @@ +import { PatchAsset } from '@asset-sg/shared'; +import { User } from '@asset-sg/shared/v2'; +import { Role } from '@asset-sg/shared/v2'; +import { AssetEditPolicy } from '@asset-sg/shared/v2'; +import { Controller, Get, HttpException, HttpStatus, Param, ParseIntPipe, Post, Put } from '@nestjs/common'; +import * as E from 'fp-ts/Either'; +import { authorize } from '@/core/authorize'; +import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { ParseBody } from '@/core/decorators/parse.decorator'; +import { AssetEditRepo } from '@/features/asset-edit/asset-edit.repo'; +import { AssetEditDetail, AssetEditService } from '@/features/asset-edit/asset-edit.service'; + +@Controller('/asset-edit') +export class AssetEditController { + constructor(private readonly assetEditRepo: AssetEditRepo, private readonly assetEditService: AssetEditService) {} + + @Get('/:id') + async show(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User): Promise { + const record = await this.assetEditRepo.find(id); + if (record == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + authorize(AssetEditPolicy, user).canShow(record); + return AssetEditDetail.encode(record); + } + + @Post('/') + async create(@ParseBody(PatchAsset) patch: PatchAsset, @CurrentUser() user: User) { + authorize(AssetEditPolicy, user).canCreate(); + validatePatch(user, patch); + const result = await this.assetEditService.createAsset(user, patch)(); + if (E.isLeft(result)) { + throw new HttpException(result.left.message, 500); + } + return result.right; + } + + @Put('/:id') + async update( + @Param('id', ParseIntPipe) id: number, + @ParseBody(PatchAsset) patch: PatchAsset, + @CurrentUser() user: User + ) { + const record = await this.assetEditRepo.find(id); + if (record == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + + authorize(AssetEditPolicy, user).canUpdate(record); + validatePatch(user, patch, record); + + const result = await this.assetEditService.updateAsset(user, record.assetId, patch)(); + if (E.isLeft(result)) { + throw new HttpException(result.left.message, 500); + } + return result.right; + } +} + +const validatePatch = (user: User, patch: PatchAsset, record?: AssetEditDetail) => { + const policy = new AssetEditPolicy(user); + + // Specialization of the policy where we disallow assets to be moved to another workgroup + // if the current user is not an editor for that workgroup. + if (!policy.canDoEverything() && !policy.hasRole(Role.Editor, patch.workgroupId)) { + throw new HttpException( + "Can't move asset to a workgroup for which the user is not an editor", + HttpStatus.FORBIDDEN + ); + } + + // Specialization of the policy where we disallow the internal status to be changed to anything else than `tobechecked` + // if the current user is not a master-editor for the asset's current or future workgroup. + const hasInternalUseChanged = + record == null || record.internalUse.statusAssetUseItemCode !== patch.internalUse.statusAssetUseItemCode; + if ( + hasInternalUseChanged && + patch.internalUse.statusAssetUseItemCode !== 'tobechecked' && + ((record != null && !policy.hasRole(Role.MasterEditor, record.workgroupId)) || + !policy.hasRole(Role.MasterEditor, patch.workgroupId)) + ) { + throw new HttpException("Changing the asset's status is not allowed", HttpStatus.FORBIDDEN); + } +}; diff --git a/apps/server-asset-sg/src/features/asset-old/asset-edit.fake.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts similarity index 89% rename from apps/server-asset-sg/src/features/asset-old/asset-edit.fake.ts rename to apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts index 19acab7e..eb182a55 100644 --- a/apps/server-asset-sg/src/features/asset-old/asset-edit.fake.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts @@ -1,4 +1,6 @@ -import { AssetUsage, Contact, PatchAsset, User, UserRoleEnum, dateIdFromDate } from '@asset-sg/shared'; +import { AssetUsage, Contact, PatchAsset, dateIdFromDate } from '@asset-sg/shared'; +import { User, WorkgroupId } from '@asset-sg/shared/v2'; +import { Role } from '@asset-sg/shared/v2'; import { fakerDE_CH as faker } from '@faker-js/faker'; import * as O from 'fp-ts/Option'; @@ -22,13 +24,17 @@ export const fakeAssetUsage = (): AssetUsage => ({ statusAssetUseItemCode: faker.helpers.arrayElement(['tobechecked', 'underclarification', 'approved']), }); -export const fakeUser = () => - define({ +export const fakeUser = () => { + const roles = new Map(); + roles.set(1, Role.Viewer); + return define({ email: faker.internet.email(), id: faker.string.uuid(), lang: faker.helpers.fromRegExp(/[a-z]{2}/), - role: faker.helpers.arrayElement(Object.values(UserRoleEnum)), + isAdmin: false, + roles, }); +}; export const fakeContact = () => define>({ @@ -64,6 +70,7 @@ export const fakeAssetPatch = (): PatchAsset => ({ titleOriginal: faker.music.songName(), titlePublic: faker.commerce.productName(), typeNatRels: [], + workgroupId: 1, }); export const fakeAssetEditDetail = (): AssetEditDetail => ({ @@ -97,4 +104,5 @@ export const fakeAssetEditDetail = (): AssetEditDetail => ({ studies: [], subordinateAssets: [], typeNatRels: [], + workgroupId: 1, }); diff --git a/apps/server-asset-sg/src/features/asset-edit/asset-edit.http b/apps/server-asset-sg/src/features/asset-edit/asset-edit.http new file mode 100644 index 00000000..b11027c7 --- /dev/null +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.http @@ -0,0 +1,75 @@ +### Get asset-edit +GET {{host}}/api/asset-edit/5 +Authorization: Impersonate {{user}} + +### Create asset-edit +POST {{host}}/api/asset-edit +Authorization: Impersonate {{user}} +Content-Type: application/json + +{ + "assetContacts": [], + "assetFormatItemCode": "pdf", + "assetKindItemCode": "report", + "assetMainId": null, + "createDate": 20200409, + "ids": [], + "internalUse": { + "isAvailable": false, + "startAvailabilityDate": 20230303, + "statusAssetUseItemCode": "tobechecked" + }, + "publicUse": { + "isAvailable": true, + "startAvailabilityDate": 20220706, + "statusAssetUseItemCode": "underclarification" + }, + "isNatRel": false, + "assetLanguages": [], + "manCatLabelRefs": [], + "newStatusWorkItemCode": null, + "newStudies": [], + "receiptDate": 20240709, + "siblingAssetIds": [], + "studies": [], + "titleOriginal": "My Cool Asset", + "titlePublic": "Our Cool Asset", + "typeNatRels": [], + "workgroupId": 1 +} + +### Update asset-edit +PUT {{host}}/api/asset-edit/5 +Authorization: Impersonate {{user}} +Content-Type: application/json + +{ + "assetContacts": [], + "assetFormatItemCode": "pdf", + "assetKindItemCode": "report", + "assetMainId": null, + "createDate": 20200409, + "ids": [], + "internalUse": { + "isAvailable": false, + "startAvailabilityDate": 20230303, + "statusAssetUseItemCode": "tobechecked" + }, + "publicUse": { + "isAvailable": true, + "startAvailabilityDate": 20220706, + "statusAssetUseItemCode": "underclarification" + }, + "isNatRel": false, + "assetLanguages": [], + "manCatLabelRefs": [], + "newStatusWorkItemCode": null, + "newStudies": [], + "receiptDate": 20240709, + "siblingAssetIds": [], + "studies": [], + "titleOriginal": "My Cool Asset", + "titlePublic": "Our Cool Asset", + "typeNatRels": [], + "workgroupId": 1 +} diff --git a/apps/server-asset-sg/src/features/asset-old/asset-edit.repo.spec.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.spec.ts similarity index 98% rename from apps/server-asset-sg/src/features/asset-old/asset-edit.repo.spec.ts rename to apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.spec.ts index 20f85406..913a66ea 100644 --- a/apps/server-asset-sg/src/features/asset-old/asset-edit.repo.spec.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.spec.ts @@ -143,6 +143,7 @@ describe(AssetEditRepo, () => { expect(record.statusWorks[0].statusWorkItemCode).toEqual('initiateAsset'); expect(record.statusWorks[0].statusWorkDate.getTime()).toBeLessThan(new Date().getTime()); expect(record.assetFiles).toEqual([]); + expect(record.workgroupId).toEqual(patch.workgroupId); }); }); @@ -200,6 +201,7 @@ describe(AssetEditRepo, () => { expect(updated.statusWorks[0].statusWorkItemCode).toEqual('initiateAsset'); expect(updated.statusWorks[0].statusWorkDate.getTime()).toBeLessThan(new Date().getTime()); expect(updated.assetFiles).toEqual([]); + expect(updated.workgroupId).toEqual(patch.workgroupId); }); }); diff --git a/apps/server-asset-sg/src/features/asset-old/asset-edit.repo.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.ts similarity index 94% rename from apps/server-asset-sg/src/features/asset-old/asset-edit.repo.ts rename to apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.ts index 7b878df0..91369073 100644 --- a/apps/server-asset-sg/src/features/asset-old/asset-edit.repo.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.ts @@ -1,5 +1,6 @@ import { decodeError, isNotNull } from '@asset-sg/core'; -import { AssetUsage, DateIdFromDate, PatchAsset, User, dateFromDateId } from '@asset-sg/shared'; +import { AssetUsage, dateFromDateId, DateIdFromDate, PatchAsset } from '@asset-sg/shared'; +import { User } from '@asset-sg/shared/v2'; import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import * as E from 'fp-ts/Either'; @@ -18,7 +19,7 @@ import { } from '@/utils/postgres-studies/postgres-studies'; @Injectable() -export class AssetEditRepo implements Repo { +export class AssetEditRepo implements Repo { constructor(private readonly prismaService: PrismaService) {} async find(id: number): Promise { @@ -32,6 +33,17 @@ export class AssetEditRepo implements Repo { return this.loadDetail(asset); } + async findByFile(fileId: number): Promise { + const asset = await this.prismaService.asset.findFirst({ + where: { assetFiles: { some: { fileId } } }, + select: selectPrismaAsset, + }); + if (asset === null) { + return null; + } + return this.loadDetail(asset); + } + async list({ limit, offset, ids }: RepoListOptions = {}): Promise { const assets = await this.prismaService.asset.findMany({ select: selectPrismaAsset, @@ -47,9 +59,9 @@ export class AssetEditRepo implements Repo { return await Promise.all(assets.map((it) => this.loadDetail(it))); } - async create(data: AssetData): Promise { + async create(data: AssetEditData): Promise { const asset = await this.prismaService.asset.create({ - select: selectPrismaAsset, + select: { assetId: true }, data: { titlePublic: data.patch.titlePublic, titleOriginal: data.patch.titleOriginal, @@ -98,12 +110,13 @@ export class AssetEditRepo implements Repo { statusWorkItemCode: 'initiateAsset', }, }, + workgroup: { connect: { id: data.patch.workgroupId } }, }, }); return (await this.find(asset.assetId)) as AssetEditDetail; } - async update(id: number, data: AssetData): Promise { + async update(id: number, data: AssetEditData): Promise { // Check if a record for `id` exists, and return `null` if not. const count = await this.prismaService.asset.count({ where: { assetId: id } }); if (count === 0) { @@ -181,6 +194,7 @@ export class AssetEditRepo implements Repo { } : [], }, + workgroupId: data.patch.workgroupId, }, }); @@ -310,7 +324,7 @@ export class AssetEditRepo implements Repo { /** * The data required to create or update an {@link AssetEditDetail}. */ -export interface AssetData { +export interface AssetEditData { patch: PatchAsset; user: User; } @@ -354,6 +368,7 @@ const selectPrismaAsset = selectOnAsset({ siblingYAssets: { select: { assetX: { select: { assetId: true, titlePublic: true } } } }, statusWorks: { select: { statusWorkItemCode: true, statusWorkDate: true } }, assetFiles: { select: { file: true } }, + workgroupId: true, }); /** diff --git a/apps/server-asset-sg/src/features/asset-old/asset-edit.service.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.service.ts similarity index 91% rename from apps/server-asset-sg/src/features/asset-old/asset-edit.service.ts rename to apps/server-asset-sg/src/features/asset-edit/asset-edit.service.ts index 1077c3da..c7d90c36 100644 --- a/apps/server-asset-sg/src/features/asset-old/asset-edit.service.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.service.ts @@ -1,5 +1,6 @@ import { isNotNull, unknownToUnknownError } from '@asset-sg/core'; -import { BaseAssetEditDetail, PatchAsset, User } from '@asset-sg/shared'; +import { BaseAssetEditDetail, PatchAsset } from '@asset-sg/shared'; +import { User } from '@asset-sg/shared/v2'; import { Injectable } from '@nestjs/common'; import { pipe } from 'fp-ts/function'; import * as TE from 'fp-ts/TaskEither'; @@ -22,15 +23,15 @@ export type AssetEditDetail = C.TypeOf; @Injectable() export class AssetEditService { constructor( - private readonly assetRepo: AssetEditRepo, + private readonly assetEditRepo: AssetEditRepo, private readonly prismaService: PrismaService, private readonly assetSearchService: AssetSearchService ) {} public createAsset(user: User, patch: PatchAsset) { return pipe( - TE.tryCatch(() => this.assetRepo.create({ user, patch }), unknownToUnknownError), - TE.chain(({ assetId }) => TE.tryCatch(() => this.assetRepo.find(assetId), unknownToUnknownError)), + TE.tryCatch(() => this.assetEditRepo.create({ user, patch }), unknownToUnknownError), + TE.chain(({ assetId }) => TE.tryCatch(() => this.assetEditRepo.find(assetId), unknownToUnknownError)), TE.chainW(TE.fromPredicate(isNotNull, notFoundError)), TE.tap((asset) => TE.tryCatch(() => this.assetSearchService.register(asset), unknownToUnknownError)), TE.map((asset) => AssetEditDetail.encode(asset)) @@ -39,7 +40,7 @@ export class AssetEditService { public updateAsset(user: User, assetId: number, patch: PatchAsset) { return pipe( - TE.tryCatch(() => this.assetRepo.update(assetId, { user, patch }), unknownToUnknownError), + TE.tryCatch(() => this.assetEditRepo.update(assetId, { user, patch }), unknownToUnknownError), TE.chainW(TE.fromPredicate(isNotNull, notFoundError)), TE.tap((asset) => TE.tryCatch(() => this.assetSearchService.register(asset), unknownToUnknownError)), TE.map((asset) => AssetEditDetail.encode(asset)) diff --git a/apps/server-asset-sg/src/features/asset-old/asset.controller.ts b/apps/server-asset-sg/src/features/asset-old/asset.controller.ts deleted file mode 100644 index 78097c65..00000000 --- a/apps/server-asset-sg/src/features/asset-old/asset.controller.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { DT, unknownToError } from '@asset-sg/core'; -import { Controller, Get, HttpException, Param, Query, Req, Res } from '@nestjs/common'; -import { Request, Response } from 'express'; -import * as E from 'fp-ts/Either'; -import * as D from 'io-ts/Decoder'; - -import { AssetService } from '@/features/asset-old/asset.service'; -import { AssetSearchService } from '@/features/assets/search/asset-search.service'; -import { isNotFoundError } from '@/utils/errors'; - -@Controller('/') -export class AssetController { - constructor(private readonly assetService: AssetService, private readonly assetSearchService: AssetSearchService) {} - - @Get('/asset') - // async findAssetsByPolygon(@Query() polygon: [number, number][]) { - async findAssetsByPolygon(@Req() req: Request) { - // const e = pipe( - // TE.fromEither(AssetSearchParams.decode(req.query)), - // TE.chainW(a => { - // switch (a.filterKind) { - // case 'polygon': - // return findAssetsByPolygon(a.polygon); - // case 'searchText': - // return TE.of(1); - // } - // }), - // ); - // console.log(JSON.stringify(pipe(AssetSearchParams.decode(req.query), E.mapLeft(D.draw)), null, 2)); - // return 'adsf;'; - const e = await this.assetService.searchAssets(req.query)(); - if (E.isLeft(e)) { - console.error(e.left); - throw new HttpException(e.left.message, 500); - } - return e.right; - } - - @Get('/search-asset') - async searchAsset(@Query('searchText') searchText: string) { - try { - return this.assetSearchService.searchOld(searchText, { - scope: ['titlePublic', 'titleOriginal', 'contactNames', 'sgsId'], - }); - } catch (e) { - const error = unknownToError(e); - console.error(error); - throw new HttpException(error.message, 500); - } - } - - @Get('/asset-detail/:assetId') - async getAssetDetail(@Param('assetId') assetId: string) { - const maybeAssetId = DT.IntFromString.decode(assetId); - if (E.isLeft(maybeAssetId)) { - throw new HttpException(D.draw(maybeAssetId.left), 400); - } - - const e = await this.assetService.getAssetDetail(maybeAssetId.right)(); - if (E.isLeft(e)) { - console.error(e.left); - if (isNotFoundError(e.left)) { - throw new HttpException('Resource not found', 400); - } - throw new HttpException(e.left.message, 500); - } - return e.right; - } - - @Get('/reference-data') - async getReferenceData() { - const e = await this.assetService.getReferenceData()(); - if (E.isLeft(e)) { - console.error(e.left); - throw new HttpException(e.left.message, 500); - } - return e.right; - } - - @Get('/file/:fileId') - async getFile(@Res() res: Response, @Param('fileId') fileId: string) { - const maybeFileId = DT.IntFromString.decode(fileId); - if (E.isLeft(maybeFileId)) { - throw new HttpException(D.draw(maybeFileId.left), 400); - } - - const e = await this.assetService.getFile(maybeFileId.right)(); - if (E.isLeft(e)) { - throw new HttpException(e.left.message, 500); - } - const result = e.right; - if (result.contentType) { - res.header('Content-Type', result.contentType); - } - if (result.contentLength != null) { - res.header('Content-Length', result.contentLength.toString()); - } - res.setHeader('Content-disposition', `filename="${result.fileName}"`); - - e.right.stream.pipe(res); - } -} diff --git a/apps/server-asset-sg/src/features/asset-old/asset.service.ts b/apps/server-asset-sg/src/features/asset-old/asset.service.ts deleted file mode 100644 index ffbb93b4..00000000 --- a/apps/server-asset-sg/src/features/asset-old/asset.service.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { decodeError, isNotNil, unknownToError, unknownToUnknownError } from '@asset-sg/core'; -import { AssetSearchParams, BaseAssetDetail, SearchAssetResult } from '@asset-sg/shared'; -import { Injectable } from '@nestjs/common'; -import { sequenceS } from 'fp-ts/Apply'; -import * as A from 'fp-ts/Array'; -import { flow, Lazy, pipe } from 'fp-ts/function'; -import * as O from 'fp-ts/Option'; -import * as RR from 'fp-ts/ReadonlyRecord'; -import * as TE from 'fp-ts/TaskEither'; -import * as C from 'io-ts/Codec'; -import * as D from 'io-ts/Decoder'; - -import { PrismaService } from '@/core/prisma.service'; -import { AssetSearchService } from '@/features/assets/search/asset-search.service'; -import { findAssetsByPolygon } from '@/features/search/find-assets-by-polygon'; -import { AssetDetailFromPostgres } from '@/models/AssetDetailFromPostgres'; -import { notFoundError } from '@/utils/errors'; -import { getFile } from '@/utils/file/get-file'; -import { postgresStudiesByAssetId } from '@/utils/postgres-studies/postgres-studies'; - -@Injectable() -export class AssetService { - constructor(private readonly prismaService: PrismaService, private readonly assetSearchService: AssetSearchService) {} - - getFile(fileId: number) { - return getFile(this.prismaService, fileId); - } - - searchAssets(query: unknown): TE.TaskEither { - return pipe( - TE.fromEither(AssetSearchParams.decode(query)), - TE.mapLeft((e) => new Error(D.draw(e))), - TE.chainW((a) => { - switch (a.filterKind) { - case 'polygon': - return pipe( - a.searchText, - O.fold( - () => findAssetsByPolygon(this.prismaService, a.polygon), - (searchText) => - pipe( - findAssetsByPolygon(this.prismaService, a.polygon), - TE.chainW( - SearchAssetResult.matchStrict({ - SearchAssetResultNonEmpty: (result) => - TE.tryCatch( - () => - this.assetSearchService.searchOld(searchText, { - scope: ['titlePublic', 'titleOriginal', 'contactNames'], - assetIds: result.assets.map((asset) => asset.assetId), - }), - unknownToError - ), - SearchAssetResultEmpty: TE.of, - }) - ) - ) - ) - ); - case 'searchText': - // TODO: now callSearchAssets with O.none as third parameter - return TE.of(1) as unknown as TE.TaskEither; - } - }) - ); - } - - getReferenceData() { - const qt = (f: Lazy>, key: K, newKey: string) => - pipe( - TE.tryCatch(f, unknownToError), - TE.map( - flow( - A.map(({ [key]: _key, ...rest }) => [_key as string, { [newKey]: _key, ...rest }] as const), - RR.fromEntries - ) - ) - ); - - const queries = { - assetFormatItems: qt(() => this.prismaService.assetFormatItem.findMany(), 'assetFormatItemCode', 'code'), - assetKindItems: qt(() => this.prismaService.assetKindItem.findMany(), 'assetKindItemCode', 'code'), - autoCatLabelItems: qt(() => this.prismaService.autoCatLabelItem.findMany(), 'autoCatLabelItemCode', 'code'), - autoObjectCatItems: qt(() => this.prismaService.autoObjectCatItem.findMany(), 'autoObjectCatItemCode', 'code'), - contactKindItems: qt(() => this.prismaService.contactKindItem.findMany(), 'contactKindItemCode', 'code'), - geomQualityItems: qt(() => this.prismaService.geomQualityItem.findMany(), 'geomQualityItemCode', 'code'), - languageItems: qt(() => this.prismaService.languageItem.findMany(), 'languageItemCode', 'code'), - legalDocItems: qt(() => this.prismaService.legalDocItem.findMany(), 'legalDocItemCode', 'code'), - manCatLabelItems: qt(() => this.prismaService.manCatLabelItem.findMany(), 'manCatLabelItemCode', 'code'), - natRelItems: qt(() => this.prismaService.natRelItem.findMany(), 'natRelItemCode', 'code'), - pubChannelItems: qt(() => this.prismaService.pubChannelItem.findMany(), 'pubChannelItemCode', 'code'), - statusAssetUseItems: qt(() => this.prismaService.statusAssetUseItem.findMany(), 'statusAssetUseItemCode', 'code'), - statusWorkItems: qt(() => this.prismaService.statusWorkItem.findMany(), 'statusWorkItemCode', 'code'), - contacts: qt(() => this.prismaService.contact.findMany(), 'contactId', 'id'), - }; - - return pipe(queries, sequenceS(TE.ApplicativeSeq)); - } - - getAssetDetail(assetId: number) { - const AssetDetail = C.struct({ - ...BaseAssetDetail, - studies: C.array(C.struct({ assetId: C.number, studyId: C.string, geomText: C.string })), - }); - return pipe( - TE.tryCatch( - () => - this.prismaService.asset.findUnique({ - where: { assetId }, - select: { - assetId: true, - titlePublic: true, - titleOriginal: true, - createDate: true, - lastProcessedDate: true, - assetKindItemCode: true, - assetFormatItemCode: true, - assetLanguages: { - select: { - languageItem: true, - }, - }, - internalUse: { select: { isAvailable: true } }, - publicUse: { select: { isAvailable: true } }, - ids: { select: { id: true, description: true } }, - assetContacts: { - select: { - role: true, - contact: { select: { name: true, locality: true, contactKindItemCode: true } }, - }, - }, - manCatLabelRefs: { select: { manCatLabelItemCode: true } }, - assetFormatCompositions: { select: { assetFormatItemCode: true } }, - typeNatRels: { select: { natRelItemCode: true } }, - assetMain: { select: { assetId: true, titlePublic: true } }, - subordinateAssets: { select: { assetId: true, titlePublic: true } }, - siblingYAssets: { select: { assetX: { select: { assetId: true, titlePublic: true } } } }, - siblingXAssets: { select: { assetY: { select: { assetId: true, titlePublic: true } } } }, - statusWorks: { select: { statusWorkItemCode: true, statusWorkDate: true } }, - assetFiles: { select: { file: true } }, - }, - }), - unknownToUnknownError - ), - TE.chainW(TE.fromPredicate(isNotNil, notFoundError)), - TE.chainW((a) => - pipe( - postgresStudiesByAssetId(this.prismaService, a.assetId), - TE.map((studies) => ({ ...a, studies })) - ) - ), - TE.chainW((a) => pipe(TE.fromEither(AssetDetailFromPostgres.decode(a)), TE.mapLeft(decodeError))), - TE.map(AssetDetail.encode) - ); - } -} diff --git a/apps/server-asset-sg/src/features/assets/asset-info.repo.ts b/apps/server-asset-sg/src/features/assets/asset-info.repo.ts index 7613860d..f174d8bb 100644 --- a/apps/server-asset-sg/src/features/assets/asset-info.repo.ts +++ b/apps/server-asset-sg/src/features/assets/asset-info.repo.ts @@ -1,6 +1,6 @@ +import { AssetId, AssetInfo } from '@asset-sg/shared/v2'; import { PrismaService } from '@/core/prisma.service'; import { ReadRepo, RepoListOptions } from '@/core/repo'; -import { AssetId, AssetInfo } from '@/features/assets/asset.model'; import { assetInfoSelection, parseAssetInfoFromPrisma } from '@/features/assets/prisma-asset'; export class AssetInfoRepo implements ReadRepo { diff --git a/apps/server-asset-sg/src/features/assets/asset.model.ts b/apps/server-asset-sg/src/features/assets/asset.model.ts deleted file mode 100644 index cf4baf13..00000000 --- a/apps/server-asset-sg/src/features/assets/asset.model.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { Transform, Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsDate, IsEnum, IsInt, IsObject, IsString, ValidateNested } from 'class-validator'; - -import { IsNullable, messageNullableInt, messageNullableString } from '@/core/decorators/is-nullable.decorator'; -import { StudyType } from '@/features/studies/study.model'; -import { LocalDate } from '@/utils/data/local-date'; -import { Data, Model } from '@/utils/data/model'; - -// `usageCode` will need to be determined in the frontend - it is no longer included here. -// See `makeUsageCode`. - -// `assetFormatCompositions` seems to be fully unused. -// The table on INT is empty, and there's no way to edit it. -// The field would theoretically be displayed in the search, but since it is empty, -// it's always skipped. - -export interface AssetInfo extends Model { - title: string; - originalTitle: string | null; - - kindCode: string; - formatCode: string; - identifiers: AssetIdentifier[]; - languageCodes: string[]; - contactAssignments: ContactAssignment[]; - manCatLabelCodes: string[]; - natRelCodes: string[]; - links: AssetLinks; - files: FileReference[]; - - createdAt: LocalDate; - receivedAt: LocalDate; - lastProcessedAt: Date; -} - -export interface AssetLinks { - parent: LinkedAsset | null; - children: LinkedAsset[]; - siblings: LinkedAsset[]; -} - -export interface AssetLinksData { - parent: AssetId | null; - siblings: AssetId[]; -} - -// Detailed data about an asset. -// These are the parts of `Asset` that were previously only part of `AssetEdit`. -// They are only visible on the asset edit page. -export interface AssetDetails { - sgsId: number | null; - municipality: string | null; - processor: string | null; - isNatRel: boolean; - infoGeol: InfoGeol; - usage: AssetUsages; - statuses: WorkStatus[]; - studies: AssetStudy[]; -} - -export interface AssetUsages { - public: AssetUsage; - internal: AssetUsage; -} - -export type Asset = AssetInfo & AssetDetails; - -type NonDataKeys = 'processor' | 'identifiers' | 'studies' | 'statuses' | 'links' | 'lastProcessedAt' | 'files'; - -export interface AssetData extends Omit, NonDataKeys> { - links: AssetLinksData; - identifiers: (AssetIdentifier | AssetIdentifierData)[]; - statuses: (WorkStatus | WorkStatusData)[]; - studies: (AssetStudy | StudyData)[]; -} - -interface InfoGeol { - main: string | null; - contact: string | null; - auxiliary: string | null; -} - -export interface AssetIdentifier extends Model { - name: string; - description: string; -} - -export type AssetIdentifierId = number; -export type AssetIdentifierData = Data; - -export type AssetId = number; - -export interface AssetUsage { - isAvailable: boolean; - statusCode: UsageStatusCode; - availableAt: LocalDate | null; -} - -export enum UsageStatusCode { - ToBeChecked = 'tobechecked', - UnderClarification = 'underclarification', - Approved = 'approved', -} - -export interface ContactAssignment { - contactId: number; - role: ContactAssignmentRole; -} - -export enum ContactAssignmentRole { - Author = 'author', - Initiator = 'initiator', - Supplier = 'supplier', -} - -export interface LinkedAsset { - id: AssetId; - title: string; -} - -export interface WorkStatus extends Model { - itemCode: WorkStatusCode; - createdAt: Date; -} - -export type WorkStatusCode = string; -export type WorkStatusData = Data; - -export interface FileReference { - id: number; - name: string; - size: number; -} - -export enum UsageCode { - Public = 'public', - Internal = 'internal', - UseOnRequest = 'useOnRequest', -} - -export interface AssetStudy extends Model { - geom: string; - type: StudyType; -} - -export type StudyData = Data; - -export type AssetStudyId = number; - -export class AssetUsageBoundary implements AssetUsage { - @IsBoolean() - isAvailable!: boolean; - - @IsEnum(UsageStatusCode) - statusCode!: UsageStatusCode; - - @IsNullable() - @ValidateNested() - @Type(() => String) - @Transform(({ value }) => LocalDate.parse(value)) - availableAt!: LocalDate | null; -} - -export class AssetUsagesBoundary implements AssetUsages { - @IsObject() - @ValidateNested() - @Type(() => AssetUsageBoundary) - public!: AssetUsageBoundary; - - @IsObject() - @ValidateNested() - @Type(() => AssetUsageBoundary) - internal!: AssetUsageBoundary; -} - -export class InfoGeolBoundary implements InfoGeol { - @IsString({ message: messageNullableString }) - @IsNullable() - main!: string | null; - - @IsString({ message: messageNullableString }) - @IsNullable() - contact!: string | null; - - @IsString({ message: messageNullableString }) - @IsNullable() - auxiliary!: string | null; -} - -export class ContactAssignmentBoundary implements ContactAssignment { - @IsInt() - contactId!: number; - - @IsEnum(ContactAssignmentRole) - role!: ContactAssignmentRole; -} - -export class StudyDataBoundary implements StudyData { - @IsInt({ message: messageNullableInt }) - @IsNullable() - id?: number | undefined; - - @IsString() - geom!: string; - - @IsEnum(StudyType) - type!: StudyType; -} - -export class WorkStatusBoundary implements WorkStatusData { - @IsInt({ message: messageNullableInt }) - @IsNullable() - id?: number | undefined; - - @IsDate() - @Type(() => Date) - createdAt!: Date; - - @IsString() - itemCode!: string; -} - -export class AssetIdentifierBoundary implements AssetIdentifierData { - @IsInt({ message: messageNullableInt }) - @IsNullable() - id?: number | undefined; - - @IsString() - name!: string; - - @IsString() - description!: string; -} - -export class AssetLinksDataBoundary implements AssetLinksData { - @IsInt({ message: messageNullableInt }) - @IsNullable() - parent!: number | null; - - @IsInt({ each: true }) - siblings!: number[]; -} - -export class AssetDataBoundary implements AssetData { - @IsObject() - @ValidateNested() - @Type(() => AssetLinksDataBoundary) - links!: AssetLinksDataBoundary; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => AssetIdentifierBoundary) - identifiers!: AssetIdentifierBoundary[]; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => WorkStatusBoundary) - statuses!: WorkStatusBoundary[]; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => StudyDataBoundary) - studies!: StudyDataBoundary[]; - - @IsString() - title!: string; - - @IsString({ message: messageNullableString }) - @IsNullable() - originalTitle!: string | null; - - @IsString() - kindCode!: string; - - @IsString() - formatCode!: string; - - @IsString({ each: true }) - languageCodes!: string[]; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => ContactAssignmentBoundary) - contactAssignments!: ContactAssignmentBoundary[]; - - @IsString({ each: true }) - manCatLabelCodes!: string[]; - - @IsString({ each: true }) - natRelCodes!: string[]; - - @ValidateNested() - @Type(() => String) - @Transform(({ value }) => LocalDate.tryParse(value)) - createdAt!: LocalDate; - - @ValidateNested() - @Type(() => String) - @Transform(({ value }) => LocalDate.tryParse(value)) - receivedAt!: LocalDate; - - @IsInt({ message: messageNullableInt }) - @IsNullable() - sgsId!: number | null; - - @IsString({ message: messageNullableString }) - @IsNullable() - municipality!: string | null; - - @IsBoolean() - isNatRel!: boolean; - - @IsObject() - @ValidateNested() - @Type(() => InfoGeolBoundary) - infoGeol!: InfoGeolBoundary; - - @IsObject() - @ValidateNested() - @Type(() => AssetUsagesBoundary) - usage!: AssetUsagesBoundary; -} diff --git a/apps/server-asset-sg/src/features/assets/asset.repo.ts b/apps/server-asset-sg/src/features/assets/asset.repo.ts index c331094e..c616982d 100644 --- a/apps/server-asset-sg/src/features/assets/asset.repo.ts +++ b/apps/server-asset-sg/src/features/assets/asset.repo.ts @@ -1,26 +1,17 @@ +import { Asset, AssetData, AssetId, AssetStudy, AssetStudyId, AssetUsage, StudyData } from '@asset-sg/shared/v2'; +import { isNotPersisted, isPersisted } from '@asset-sg/shared/v2'; +import { StudyType } from '@asset-sg/shared/v2'; +import { User } from '@asset-sg/shared/v2'; import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; - import { PrismaService } from '@/core/prisma.service'; -import { Repo, RepoListOptions } from '@/core/repo'; -import { - Asset, - AssetData, - AssetId, - AssetUsage, - AssetStudy, - StudyData, - AssetStudyId, -} from '@/features/assets/asset.model'; +import { FindRepo, MutateRepo } from '@/core/repo'; import { assetSelection, parseAssetFromPrisma } from '@/features/assets/prisma-asset'; -import { StudyType } from '@/features/studies/study.model'; -import { User } from '@/features/users/user.model'; -import { isNotPersisted, isPersisted } from '@/utils/data/model'; import { satisfy } from '@/utils/define'; import { handlePrismaMutationError } from '@/utils/prisma'; @Injectable() -export class AssetRepo implements Repo { +export class AssetRepo implements FindRepo, MutateRepo { constructor(private readonly prisma: PrismaService) {} async find(id: AssetId): Promise { @@ -31,21 +22,6 @@ export class AssetRepo implements Repo { return entry == null ? null : parseAssetFromPrisma(entry); } - async list({ limit, offset, ids }: RepoListOptions = {}): Promise { - const entries = await this.prisma.asset.findMany({ - where: - ids == null - ? undefined - : { - assetId: { in: ids }, - }, - select: assetSelection, - take: limit, - skip: offset, - }); - return entries.map(parseAssetFromPrisma); - } - async create(data: FullAssetData): Promise { const id = await this.prisma.$transaction(async () => { const { assetId } = await this.prisma.asset.create({ @@ -157,11 +133,9 @@ export class AssetRepo implements Repo { const condition = knownIds.length === 0 ? '' : Prisma.sql`AND study_${Prisma.raw(type)}_id NOT IN (${Prisma.join(knownIds, ',')})`; await this.prisma.$queryRaw` - DELETE FROM - public.study_${type} - WHERE - assetId = ${assetId} - ${condition} + DELETE + FROM public.study_${type} + WHERE assetId = ${assetId} ${condition} `; } @@ -175,8 +149,7 @@ export class AssetRepo implements Repo { ` ); await this.prisma.$queryRaw` - INSERT INTO - public.study_${type} + INSERT INTO public.study_${type} (asset_id, geom_quality_item_code, geom) VALUES ${Prisma.join(values, ',')} @@ -197,13 +170,11 @@ export class AssetRepo implements Repo { await this.prisma.$queryRaw` UPDATE public.study_${type} - SET - geom = + SET geom = CASE - ${Prisma.join(cases, '\n')} - ELSE geom - WHERE - assetId = ${assetId} + ${Prisma.join(cases, '\n')} + ELSE geom + WHERE assetId = ${assetId} `; } } @@ -231,6 +202,11 @@ const mapDataToPrisma = (data: FullAssetData) => assetFormatItemCode: data.formatCode, }, }, + workgroup: { + connect: { + id: data.workgroupId, + }, + }, }); const mapDataToPrismaCreate = (data: FullAssetData): Prisma.AssetCreateInput => ({ diff --git a/apps/server-asset-sg/src/features/assets/assets.controller.ts b/apps/server-asset-sg/src/features/assets/assets.controller.ts index 56a34a50..814fc848 100644 --- a/apps/server-asset-sg/src/features/assets/assets.controller.ts +++ b/apps/server-asset-sg/src/features/assets/assets.controller.ts @@ -1,5 +1,10 @@ +import { Asset, AssetData, AssetId, UsageStatusCode } from '@asset-sg/shared/v2'; + +import { User } from '@asset-sg/shared/v2'; +import { Role } from '@asset-sg/shared/v2'; +import { AssetPolicy } from '@asset-sg/shared/v2'; +import { AssetDataSchema } from '@asset-sg/shared/v2'; import { - Body, Controller, Delete, Get, @@ -10,49 +15,48 @@ import { ParseIntPipe, Post, Put, - ValidationPipe, } from '@nestjs/common'; - +import { authorize } from '@/core/authorize'; import { CurrentUser } from '@/core/decorators/current-user.decorator'; -import { RequireRole } from '@/core/decorators/require-role.decorator'; -import { AssetInfoRepo } from '@/features/assets/asset-info.repo'; -import { Asset, AssetDataBoundary, AssetId } from '@/features/assets/asset.model'; +import { ParseBody } from '@/core/decorators/parse.decorator'; import { AssetRepo } from '@/features/assets/asset.repo'; -import { Role, User } from '@/features/users/user.model'; @Controller('/assets') export class AssetsController { - constructor(private readonly assetRepo: AssetRepo, private readonly assetInfoRepo: AssetInfoRepo) {} + constructor(private readonly assetRepo: AssetRepo) {} @Get('/:id') - @RequireRole(Role.Viewer) - async show(@Param('id', ParseIntPipe) id: AssetId): Promise { - const asset = await this.assetRepo.find(id); - if (asset === null) { + async show(@Param('id', ParseIntPipe) id: AssetId, @CurrentUser() user: User): Promise { + const record = await this.assetRepo.find(id); + if (record === null) { throw new HttpException('not found', 404); } - return asset; + authorize(AssetPolicy, user).canShow(record); + return record; } @Post('/') - @RequireRole(Role.MasterEditor) - async create( - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - data: AssetDataBoundary, - @CurrentUser() user: User - ): Promise { + async create(@ParseBody(AssetDataSchema) data: AssetData, @CurrentUser() user: User): Promise { + authorize(AssetPolicy, user).canCreate(); + validateData(user, data); return await this.assetRepo.create({ ...data, processor: user }); } @Put('/:id') - @RequireRole(Role.MasterEditor) async update( - @Param('id', ParseIntPipe) id: AssetId, - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - data: AssetDataBoundary, + @Param('id', ParseIntPipe) id: number, + @ParseBody(AssetDataSchema) data: AssetData, @CurrentUser() user: User ): Promise { - const asset = await this.assetRepo.update(id, { ...data, processor: user }); + const record = await this.assetRepo.find(id); + if (record == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + + authorize(AssetPolicy, user).canUpdate(record); + validateData(user, data, record); + + const asset = await this.assetRepo.update(record.id, { ...data, processor: user }); if (asset === null) { throw new HttpException('not found', 404); } @@ -60,12 +64,38 @@ export class AssetsController { } @Delete('/:id') - @RequireRole(Role.MasterEditor) @HttpCode(HttpStatus.NO_CONTENT) - async delete(@Param('id', ParseIntPipe) id: AssetId): Promise { - const isOk = await this.assetRepo.delete(id); - if (!isOk) { - throw new HttpException('not found', 404); + async delete(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User): Promise { + const record = await this.assetRepo.find(id); + if (record == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); } + authorize(AssetPolicy, user).canDelete(record); + await this.assetRepo.delete(record.id); } } + +const validateData = (user: User, data: AssetData, record?: Asset) => { + const policy = new AssetPolicy(user); + + // Specialization of the policy where we disallow assets to be moved to another workgroup + // if the current user is not an editor for that workgroup. + if (!policy.canDoEverything() && !policy.hasRole(Role.Editor, data.workgroupId)) { + throw new HttpException( + "Can't move asset to a workgroup for which the user is not an editor", + HttpStatus.FORBIDDEN + ); + } + + // Specialization of the policy where we disallow the internal status to be changed to anything else than `tobechecked` + // if the current user is not a master-editor for the asset's current or future workgroup. + const hasInternalUseChanged = record == null || record.usage.internal.statusCode !== data.usage.internal.statusCode; + if ( + hasInternalUseChanged && + data.usage.internal.statusCode !== UsageStatusCode.ToBeChecked && + ((record != null && !policy.hasRole(Role.MasterEditor, record.workgroupId)) || + !policy.hasRole(Role.MasterEditor, data.workgroupId)) + ) { + throw new HttpException("Changing the asset's status is not allowed", HttpStatus.FORBIDDEN); + } +}; diff --git a/apps/server-asset-sg/src/features/assets/assets.http b/apps/server-asset-sg/src/features/assets/assets.http index 96b462de..d248a244 100644 --- a/apps/server-asset-sg/src/features/assets/assets.http +++ b/apps/server-asset-sg/src/features/assets/assets.http @@ -4,7 +4,7 @@ Authorization: Impersonate {{user}} Content-Type: application/json { - "title": "My Title", + "title": "My new Asset", "originalTitle": null, "municipality": null, "kindCode": "package", @@ -40,7 +40,8 @@ Content-Type: application/json "isAvailable": true, "statusCode": "approved" } - } + }, + "workgroupId": 4 } ### Update asset @@ -61,7 +62,8 @@ Content-Type: application/json "links": { "parent": 44382, "siblings": [ - 44382, 44384 + 44382, + 44384 ] }, "identifiers": [], @@ -85,9 +87,10 @@ Content-Type: application/json "isAvailable": true, "statusCode": "approved" } - } + }, + "workgroupId": 1 } -### List studies -GET {{host}}/api/all-study-short +### Get asset +GET {{host}}/api/assets/44383 Authorization: Impersonate {{user}} diff --git a/apps/server-asset-sg/src/features/assets/prisma-asset.ts b/apps/server-asset-sg/src/features/assets/prisma-asset.ts index f09428be..65518142 100644 --- a/apps/server-asset-sg/src/features/assets/prisma-asset.ts +++ b/apps/server-asset-sg/src/features/assets/prisma-asset.ts @@ -1,5 +1,3 @@ -import { Prisma } from '@prisma/client'; - import { Asset, AssetInfo, @@ -9,9 +7,11 @@ import { AssetStudy, AssetStudyId, UsageStatusCode, -} from '@/features/assets/asset.model'; -import { StudyType } from '@/features/studies/study.model'; -import { LocalDate } from '@/utils/data/local-date'; +} from '@asset-sg/shared/v2'; +import { LocalDate } from '@asset-sg/shared/v2'; + +import { StudyType } from '@asset-sg/shared/v2'; +import { Prisma } from '@prisma/client'; import { satisfy } from '@/utils/define'; type SelectedAssetInfo = Prisma.AssetGetPayload<{ select: typeof assetInfoSelection }>; @@ -98,6 +98,7 @@ export const assetInfoSelection = satisfy()({ createDate: true, receiptDate: true, lastProcessedDate: true, + workgroupId: true, }); export const assetSelection = satisfy()({ @@ -194,6 +195,7 @@ export const parseAssetFromPrisma = (data: SelectedAsset): Asset => ({ geom: it.geomText, } as AssetStudy; }), + workgroupId: data.workgroupId, }); const parseLinkedAsset = (data: SelectedLinkedAsset): LinkedAsset => ({ @@ -211,7 +213,7 @@ const parseStudyId = (studyId: string): { type: StudyType; id: AssetStudyId } => if (!studyId.startsWith('study_')) { throw new Error('expected studyId to start with `study_`'); } - const parts = studyId.substring('study_'.length).split('_', 1); + const parts = studyId.substring('study_'.length).split('_', 2); if (parts.length !== 2) { throw new Error(`invalid studyId '${studyId}'`); } diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts index 6d21584e..48e18abc 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts @@ -1,12 +1,17 @@ import { + AssetSearchQuery, AssetSearchQueryDTO, AssetSearchResult, AssetSearchResultDTO, AssetSearchStats, AssetSearchStatsDTO, } from '@asset-sg/shared'; +import { User } from '@asset-sg/shared/v2'; import { Body, Controller, HttpCode, HttpStatus, Post, Query, ValidationPipe } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; +import { Authorize } from '@/core/decorators/authorize.decorator'; +import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { ParseBody } from '@/core/decorators/parse.decorator'; import { AssetSearchService } from '@/features/assets/search/asset-search.service'; @Controller('/assets/search') @@ -14,10 +19,12 @@ export class AssetSearchController { constructor(private readonly assetSearchService: AssetSearchService) {} @Post('/') + @Authorize.User() @HttpCode(HttpStatus.OK) async search( - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - query: AssetSearchQueryDTO, + @ParseBody(AssetSearchQueryDTO) + query: AssetSearchQuery, + @CurrentUser() user: User, @Query('limit') limit?: number, @@ -27,17 +34,29 @@ export class AssetSearchController { ): Promise { limit = limit == null ? limit : Number(limit); offset = offset == null ? offset : Number(offset); + restrictQueryForUser(query, user); const result = await this.assetSearchService.search(query, { limit, offset, decode: false }); return plainToInstance(AssetSearchResultDTO, result); } @Post('/stats') + @Authorize.User() @HttpCode(HttpStatus.OK) async showStats( - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - query: AssetSearchQueryDTO + @ParseBody(AssetSearchQueryDTO) + query: AssetSearchQuery, + @CurrentUser() user: User ): Promise { + restrictQueryForUser(query, user); const stats = await this.assetSearchService.aggregate(query); return plainToInstance(AssetSearchStatsDTO, stats); } } + +const restrictQueryForUser = (query: AssetSearchQuery, user: User) => { + if (user.isAdmin) { + return; + } + query.workgroupIds = + query.workgroupIds == null ? [...user.roles.keys()] : query.workgroupIds.filter((it) => user.roles.has(it)); +}; diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts index d5738e2f..f6486124 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts @@ -29,9 +29,9 @@ import { ASSET_ELASTIC_INDEX, AssetSearchService } from './asset-search.service' import { openElasticsearchClient } from '@/core/elasticsearch'; import { PrismaService } from '@/core/prisma.service'; -import { fakeAssetPatch, fakeAssetUsage, fakeContact, fakeUser } from '@/features/asset-old/asset-edit.fake'; -import { AssetData, AssetEditRepo } from '@/features/asset-old/asset-edit.repo'; -import { AssetEditDetail } from '@/features/asset-old/asset-edit.service'; +import { fakeAssetPatch, fakeAssetUsage, fakeContact, fakeUser } from '@/features/asset-edit/asset-edit.fake'; +import { AssetEditData, AssetEditRepo } from '@/features/asset-edit/asset-edit.repo'; +import { AssetEditDetail } from '@/features/asset-edit/asset-edit.service'; import { StudyRepo } from '@/features/studies/study.repo'; describe(AssetSearchService, () => { @@ -63,7 +63,7 @@ describe(AssetSearchService, () => { }); }); - const create = async (data: AssetData): Promise => { + const create = async (data: AssetEditData): Promise => { const asset = await assetRepo.create(data); await service.register(asset); return asset; @@ -118,9 +118,10 @@ describe(AssetSearchService, () => { (text: T, setup: (asset: PatchAsset, text: T) => PatchAsset | Promise) => async () => { // Given + const user = fakeUser(); const patch = await setup(fakeAssetPatch(), text); - const asset = await create({ patch, user: fakeUser() }); - await create({ patch: fakeAssetPatch(), user: fakeUser() }); + const asset = await create({ patch, user }); + await create({ patch: fakeAssetPatch(), user }); // When const result = await service.search({ text: `${text}` }); @@ -161,13 +162,14 @@ describe(AssetSearchService, () => { it('finds assets by minimum createDate', async () => { // Given - const asset = await create({ patch: fakeAssetPatch(), user: fakeUser() }); + const user = fakeUser(); + const asset = await create({ patch: fakeAssetPatch(), user }); await create({ patch: { ...fakeAssetPatch(), createDate: dateIdFromDate(new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay * 2)), }, - user: fakeUser(), + user, }); // When @@ -183,13 +185,14 @@ describe(AssetSearchService, () => { it('finds assets by maximum createDate', async () => { // Given - const asset = await create({ patch: fakeAssetPatch(), user: fakeUser() }); + const user = fakeUser(); + const asset = await create({ patch: fakeAssetPatch(), user }); await create({ patch: { ...fakeAssetPatch(), createDate: dateIdFromDate(new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay * 2)), }, - user: fakeUser(), + user, }); // When @@ -263,9 +266,10 @@ describe(AssetSearchService, () => { const code1 = assetKindItems[0].assetKindItemCode; const code2 = assetKindItems[1].assetKindItemCode; const code3 = assetKindItems[2].assetKindItemCode; - const asset = await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code1 }, user: fakeUser() }); - await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code2 }, user: fakeUser() }); - await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code3 }, user: fakeUser() }); + const user = fakeUser(); + const asset = await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code1 }, user }); + await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code2 }, user }); + await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code3 }, user }); // When const result = await service.search({ assetKindItemCodes: [code1] }); @@ -279,9 +283,10 @@ describe(AssetSearchService, () => { const code1 = manCatLabelItems[0].manCatLabelItemCode; const code2 = manCatLabelItems[1].manCatLabelItemCode; const code3 = manCatLabelItems[2].manCatLabelItemCode; - const asset = await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code1] }, user: fakeUser() }); - await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code2] }, user: fakeUser() }); - await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code3] }, user: fakeUser() }); + const user = fakeUser(); + const asset = await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code1] }, user }); + await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code2] }, user }); + await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code3] }, user }); // When const result = await service.search({ manCatLabelItemCodes: [code1] }); @@ -293,13 +298,14 @@ describe(AssetSearchService, () => { it('finds assets by usageCode', async () => { // Given const usageCode: UsageCode = 'public'; + const user = fakeUser(); const asset = await create({ patch: { ...fakeAssetPatch(), publicUse: { ...fakeAssetUsage(), isAvailable: true }, internalUse: { ...fakeAssetUsage(), isAvailable: true }, }, - user: fakeUser(), + user, }); await create({ patch: { @@ -307,7 +313,7 @@ describe(AssetSearchService, () => { publicUse: { ...fakeAssetUsage(), isAvailable: false }, internalUse: { ...fakeAssetUsage(), isAvailable: true }, }, - user: fakeUser(), + user, }); await create({ patch: { @@ -315,7 +321,7 @@ describe(AssetSearchService, () => { publicUse: { ...fakeAssetUsage(), isAvailable: false }, internalUse: { ...fakeAssetUsage(), isAvailable: false }, }, - user: fakeUser(), + user, }); // When @@ -329,17 +335,18 @@ describe(AssetSearchService, () => { // Given const contact1 = await prisma.contact.create({ data: fakeContact() }); const contact2 = await prisma.contact.create({ data: fakeContact() }); + const user = fakeUser(); const asset = await create({ patch: { ...fakeAssetPatch(), assetContacts: [{ contactId: contact1.contactId, role: 'author' }] }, - user: fakeUser(), + user, }); await create({ patch: { ...fakeAssetPatch(), assetContacts: [{ contactId: contact1.contactId, role: 'supplier' }] }, - user: fakeUser(), + user, }); await create({ patch: { ...fakeAssetPatch(), assetContacts: [{ contactId: contact2.contactId, role: 'author' }] }, - user: fakeUser(), + user, }); // When @@ -404,7 +411,8 @@ describe(AssetSearchService, () => { it('aggregates stats for a single asset', async () => { // Given - const asset = await create({ patch: fakeAssetPatch(), user: fakeUser() }); + const user = fakeUser(); + const asset = await create({ patch: fakeAssetPatch(), user }); // When const result = await service.aggregate({}); diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts index 1075499e..bcce17a8 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts @@ -19,6 +19,7 @@ import { UsageCode, ValueCount, } from '@asset-sg/shared'; +import { AssetId, StudyId } from '@asset-sg/shared/v2'; import { Client as ElasticsearchClient } from '@elastic/elasticsearch'; import { BulkOperationContainer, @@ -33,10 +34,8 @@ import proj4 from 'proj4'; // eslint-disable-next-line @nx/enforce-module-boundaries import indexMapping from '../../../../../../development/init/elasticsearch/mappings/swissgeol_asset_asset.json'; -import { AssetId } from '../asset.model'; import { PrismaService } from '@/core/prisma.service'; -import { AssetEditRepo } from '@/features/asset-old/asset-edit.repo'; -import { StudyId } from '@/features/studies/study.model'; +import { AssetEditRepo } from '@/features/asset-edit/asset-edit.repo'; import { StudyRepo } from '@/features/studies/study.repo'; const INDEX = 'swissgeol_asset_asset'; @@ -197,7 +196,125 @@ export class AssetSearchService { * @param query The query to match with. */ async aggregate(query: AssetSearchQuery): Promise { - return await this.aggregateAssetIds(query); + interface Result { + minCreateDate: { value: DateId }; + maxCreateDate: { value: DateId }; + authorIds: { + buckets: AggregationBucket[]; + }; + assetKindItemCodes: { + buckets: AggregationBucket[]; + }; + languageItemCodes: { + buckets: AggregationBucket[]; + }; + geometryCodes: { + buckets: AggregationBucket[]; + }; + manCatLabelItemCodes: { + buckets: AggregationBucket[]; + }; + usageCodes: { + buckets: AggregationBucket[]; + }; + workgroupIds: { + buckets: AggregationBucket[]; + }; + } + + interface AggregationBucket { + key: K; + doc_count: number; + } + + const aggregateGroup = async (query: AssetSearchQuery, operator: string, groupName: string, fieldName?: string) => { + const elasticDslQuery = mapQueryToElasticDsl({ ...query, [groupName]: undefined }); + return ( + await this.elastic.search({ + index: INDEX, + size: 0, + query: elasticDslQuery, + track_total_hits: true, + aggs: { + agg: { [operator]: { field: fieldName ?? groupName } }, + }, + }) + ).aggregations?.agg; + }; + + const elasticQuery = mapQueryToElasticDsl(query); + const response = await this.elastic.search({ + index: INDEX, + size: 0, + query: elasticQuery, + track_total_hits: true, + }); + const total = (response.hits.total as SearchTotalHits).value; + if (total === 0) { + return { + total: 0, + assetKindItemCodes: [], + authorIds: [], + createDate: null, + languageItemCodes: [], + geometryCodes: [], + manCatLabelItemCodes: [], + usageCodes: [], + workgroupIds: [], + }; + } + + const [ + assetKindItemCodes, + authorIds, + languageItemCodes, + geometryCodes, + manCatLabelItemCodes, + usageCodes, + workgroupIds, + minCreateDate, + maxCreateDate, + ] = await Promise.all([ + aggregateGroup(query, 'terms', 'assetKindItemCodes', 'assetKindItemCode'), + aggregateGroup(query, 'terms', 'authorIds'), + aggregateGroup(query, 'terms', 'languageItemCodes'), + aggregateGroup(query, 'terms', 'geometryCodes'), + aggregateGroup(query, 'terms', 'manCatLabelItemCodes'), + aggregateGroup(query, 'terms', 'usageCodes', 'usageCode'), + aggregateGroup(query, 'terms', 'workgroupIds', 'workgroupId'), + aggregateGroup(query, 'min', 'minCreateDate', 'createDate'), + aggregateGroup(query, 'max', 'maxCreateDate', 'createDate'), + ]); + const aggs = { + assetKindItemCodes, + authorIds, + languageItemCodes, + geometryCodes, + manCatLabelItemCodes, + usageCodes, + workgroupIds, + minCreateDate, + maxCreateDate, + } as unknown as Result; + + const mapBucket = (bucket: AggregationBucket): ValueCount => ({ + value: bucket.key, + count: bucket.doc_count, + }); + return { + total, + assetKindItemCodes: aggs.assetKindItemCodes.buckets.map(mapBucket), + authorIds: aggs.authorIds.buckets.map(mapBucket), + languageItemCodes: aggs.languageItemCodes.buckets.map(mapBucket), + geometryCodes: aggs.geometryCodes.buckets.map(mapBucket), + manCatLabelItemCodes: aggs.manCatLabelItemCodes.buckets.map(mapBucket), + usageCodes: aggs.usageCodes.buckets.map(mapBucket), + workgroupIds: aggs.workgroupIds.buckets.map(mapBucket), + createDate: { + min: dateFromDateId(aggs.minCreateDate.value), + max: dateFromDateId(aggs.maxCreateDate.value), + }, + }; } /** @@ -293,7 +410,6 @@ export class AssetSearchService { if (response.hits.hits.length === 0) { return [matchedAssets, totalCount]; } - for (const hit of response.hits.hits) { const assetId: number = hit.fields!['assetId'][0]; const data = hit.fields!['data'][0]; @@ -306,123 +422,6 @@ export class AssetSearchService { } } - private async aggregateAssetIds(query: AssetSearchQuery): Promise { - interface Result { - minCreateDate: { value: DateId }; - maxCreateDate: { value: DateId }; - authorIds: { - buckets: AggregationBucket[]; - }; - assetKindItemCodes: { - buckets: AggregationBucket[]; - }; - languageItemCodes: { - buckets: AggregationBucket[]; - }; - geometryCodes: { - buckets: AggregationBucket[]; - }; - usageCodes: { - buckets: AggregationBucket[]; - }; - manCatLabelItemCodes: { - buckets: AggregationBucket[]; - }; - } - - const defaultResult = () => - ({ - total: 0, - assetKindItemCodes: [], - authorIds: [], - createDate: null, - languageItemCodes: [], - geometryCodes: [], - manCatLabelItemCodes: [], - usageCodes: [], - } as AssetSearchStats); - - interface AggregationBucket { - key: K; - doc_count: number; - } - - const aggregateGroup = async (query: AssetSearchQuery, operator: string, groupName: string, fieldName?: string) => { - const elasticDslQuery = mapQueryToElasticDsl({ ...query, [groupName]: undefined }); - return ( - await this.elastic.search({ - index: INDEX, - size: 0, - query: elasticDslQuery, - track_total_hits: true, - aggs: { - agg: { [operator]: { field: fieldName ?? groupName } }, - }, - }) - ).aggregations?.agg; - }; - - const elasticQuery = mapQueryToElasticDsl(query); - const response = await this.elastic.search({ - index: INDEX, - size: 0, - query: elasticQuery, - track_total_hits: true, - }); - const total = (response.hits.total as SearchTotalHits).value; - if (total === 0) { - return defaultResult(); - } - - const [ - assetKindItemCodes, - authorIds, - languageItemCodes, - geometryCodes, - manCatLabelItemCodes, - usageCodes, - minCreateDate, - maxCreateDate, - ] = await Promise.all([ - aggregateGroup(query, 'terms', 'assetKindItemCodes', 'assetKindItemCode'), - aggregateGroup(query, 'terms', 'authorIds'), - aggregateGroup(query, 'terms', 'languageItemCodes'), - aggregateGroup(query, 'terms', 'geometryCodes'), - aggregateGroup(query, 'terms', 'manCatLabelItemCodes'), - aggregateGroup(query, 'terms', 'usageCodes', 'usageCode'), - aggregateGroup(query, 'min', 'minCreateDate', 'createDate'), - aggregateGroup(query, 'max', 'maxCreateDate', 'createDate'), - ]); - const aggs = { - assetKindItemCodes, - authorIds, - languageItemCodes, - geometryCodes, - manCatLabelItemCodes, - usageCodes, - minCreateDate, - maxCreateDate, - } as unknown as Result; - - const mapBucket = (bucket: AggregationBucket): ValueCount => ({ - value: bucket.key, - count: bucket.doc_count, - }); - return { - total, - assetKindItemCodes: aggs.assetKindItemCodes.buckets.map(mapBucket), - authorIds: aggs.authorIds.buckets.map(mapBucket), - languageItemCodes: aggs.languageItemCodes.buckets.map(mapBucket), - geometryCodes: aggs.geometryCodes.buckets.map(mapBucket), - manCatLabelItemCodes: aggs.manCatLabelItemCodes.buckets.map(mapBucket), - usageCodes: aggs.usageCodes.buckets.map(mapBucket), - createDate: { - min: dateFromDateId(aggs.minCreateDate.value), - max: dateFromDateId(aggs.maxCreateDate.value), - }, - }; - } - private async searchElasticOld(query: string, { scope, assetIds }: SearchOptions): Promise { const filters: QueryDslQueryContainer[] = []; if (assetIds != null) { @@ -667,6 +666,7 @@ export class AssetSearchService { manCatLabelItemCodes: asset.manCatLabelRefs, geometryCodes: geometryCodes.length > 0 ? [...new Set(geometryCodes)] : ['None'], studyLocations, + workgroupId: asset.workgroupId, data: JSON.stringify(AssetEditDetail.encode(asset)), }; } @@ -735,6 +735,13 @@ const mapQueryToElasticDsl = (query: AssetSearchQuery): QueryDslQueryContainer = if (query.geometryCodes != null) { filters.push(makeArrayFilter('geometryCodes', query.geometryCodes)); } + if (query.workgroupIds != null) { + filters.push({ + terms: { + workgroupId: query.workgroupIds, + }, + }); + } if (query.polygon != null) { queries.push({ geo_polygon: { diff --git a/apps/server-asset-sg/src/features/assets/sync/asset-sync.controller.ts b/apps/server-asset-sg/src/features/assets/sync/asset-sync.controller.ts index da973e69..f4efc305 100644 --- a/apps/server-asset-sg/src/features/assets/sync/asset-sync.controller.ts +++ b/apps/server-asset-sg/src/features/assets/sync/asset-sync.controller.ts @@ -2,10 +2,8 @@ import fs from 'fs/promises'; import { Controller, Get, HttpException, OnApplicationBootstrap, Post, Res } from '@nestjs/common'; import { Response } from 'express'; - -import { RequireRole } from '@/core/decorators/require-role.decorator'; +import { Authorize } from '@/core/decorators/authorize.decorator'; import { AssetSearchService } from '@/features/assets/search/asset-search.service'; -import { Role } from '@/features/users/user.model'; @Controller('/assets/sync') export class AssetSyncController implements OnApplicationBootstrap { @@ -22,7 +20,7 @@ export class AssetSyncController implements OnApplicationBootstrap { } @Get('/') - @RequireRole(Role.MasterEditor) + @Authorize.Admin() async show(@Res() res: Response): Promise<{ progress: number } | void> { try { const data = await fs.readFile(assetSyncFile, { encoding: 'utf-8' }); @@ -38,7 +36,7 @@ export class AssetSyncController implements OnApplicationBootstrap { } @Post('/') - @RequireRole(Role.MasterEditor) + @Authorize.Admin() async start(@Res() res: Response): Promise { const isSyncRunning = await fs .access(assetSyncFile) diff --git a/apps/server-asset-sg/src/features/contacts/contact.repo.ts b/apps/server-asset-sg/src/features/contacts/contact.repo.ts index e704fc06..65e4bd47 100644 --- a/apps/server-asset-sg/src/features/contacts/contact.repo.ts +++ b/apps/server-asset-sg/src/features/contacts/contact.repo.ts @@ -1,9 +1,9 @@ +import { Contact, ContactData, ContactId } from '@asset-sg/shared/v2'; import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '@/core/prisma.service'; import { Repo, RepoListOptions } from '@/core/repo'; -import { Contact, ContactData, ContactId } from '@/features/contacts/contact.model'; import { satisfy } from '@/utils/define'; import { handlePrismaMutationError } from '@/utils/prisma'; diff --git a/apps/server-asset-sg/src/features/contacts/contacts.controller.ts b/apps/server-asset-sg/src/features/contacts/contacts.controller.ts index 61c79b4c..cef8c5dd 100644 --- a/apps/server-asset-sg/src/features/contacts/contacts.controller.ts +++ b/apps/server-asset-sg/src/features/contacts/contacts.controller.ts @@ -1,43 +1,36 @@ -import { - Body, - Controller, - HttpCode, - HttpException, - HttpStatus, - Param, - ParseIntPipe, - Post, - Put, - ValidationPipe, -} from '@nestjs/common'; - -import { RequireRole } from '@/core/decorators/require-role.decorator'; -import { Contact, ContactDataBoundary, ContactId } from '@/features/contacts/contact.model'; +import { Contact, ContactData } from '@asset-sg/shared/v2'; +import { User } from '@asset-sg/shared/v2'; +import { ContactPolicy } from '@asset-sg/shared/v2'; +import { ContactDataSchema } from '@asset-sg/shared/v2'; +import { Controller, HttpCode, HttpException, HttpStatus, Param, ParseIntPipe, Post, Put } from '@nestjs/common'; +import { authorize } from '@/core/authorize'; +import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { ParseBody } from '@/core/decorators/parse.decorator'; import { ContactRepo } from '@/features/contacts/contact.repo'; -import { Role } from '@/features/users/user.model'; @Controller('/contacts') export class ContactsController { constructor(private readonly contactRepo: ContactRepo) {} @Post('/') - @RequireRole(Role.Editor) @HttpCode(HttpStatus.CREATED) - create( - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - data: ContactDataBoundary - ): Promise { + create(@ParseBody(ContactDataSchema) data: ContactData, @CurrentUser() user: User): Promise { + authorize(ContactPolicy, user).canCreate(); return this.contactRepo.create(data); } @Put('/:id') - @RequireRole(Role.Editor) async update( - @Param('id', ParseIntPipe) id: ContactId, - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - data: ContactDataBoundary + @Param('id', ParseIntPipe) id: number, + @ParseBody(ContactDataSchema) data: ContactData, + @CurrentUser() user: User ): Promise { - const contact = await this.contactRepo.update(id, data); + const record = await this.contactRepo.find(id); + if (record == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + authorize(ContactPolicy, user).canUpdate(record); + const contact = await this.contactRepo.update(record.id, data); if (contact == null) { throw new HttpException('not found', 404); } diff --git a/apps/server-asset-sg/src/features/favorites/favorite.repo.ts b/apps/server-asset-sg/src/features/favorites/favorite.repo.ts index 92449a2c..6b3220ba 100644 --- a/apps/server-asset-sg/src/features/favorites/favorite.repo.ts +++ b/apps/server-asset-sg/src/features/favorites/favorite.repo.ts @@ -1,10 +1,10 @@ +import { Favorite } from '@asset-sg/shared/v2'; +import { UserId } from '@asset-sg/shared/v2'; import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '@/core/prisma.service'; import { CreateRepo, DeleteRepo, ReadRepo, RepoListOptions } from '@/core/repo'; -import { Favorite } from '@/features/favorites/favorite.model'; -import { UserId } from '@/features/users/user.model'; import { satisfy } from '@/utils/define'; import { handlePrismaMutationError } from '@/utils/prisma'; diff --git a/apps/server-asset-sg/src/features/favorites/favorites.controller.ts b/apps/server-asset-sg/src/features/favorites/favorites.controller.ts index 629f2190..f1356277 100644 --- a/apps/server-asset-sg/src/features/favorites/favorites.controller.ts +++ b/apps/server-asset-sg/src/features/favorites/favorites.controller.ts @@ -1,10 +1,11 @@ import { SearchAssetResult, SearchAssetResultEmpty } from '@asset-sg/shared'; +import { User } from '@asset-sg/shared/v2'; import { Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Put } from '@nestjs/common'; +import { Authorize } from '@/core/decorators/authorize.decorator'; import { CurrentUser } from '@/core/decorators/current-user.decorator'; import { AssetSearchService } from '@/features/assets/search/asset-search.service'; import { FavoriteRepo } from '@/features/favorites/favorite.repo'; -import { User } from '@/features/users/user.model'; import { define } from '@/utils/define'; @Controller('/users/current/favorites') @@ -13,6 +14,7 @@ export class FavoritesController { // TODO make an alternative, new endpoint for this that does not use fp-ts. @Get('/') + @Authorize.User() async list(@CurrentUser() user: User): Promise { const favorites = await this.favoriteRepo.listByUserId(user.id); if (favorites.length === 0) { @@ -23,12 +25,14 @@ export class FavoritesController { } @Put('/:assetId') + @Authorize.User() @HttpCode(HttpStatus.NO_CONTENT) async add(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { await this.favoriteRepo.create({ userId: user.id, assetId }); } @Delete('/:assetId') + @Authorize.User() @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { await this.favoriteRepo.delete({ userId: user.id, assetId }); diff --git a/apps/server-asset-sg/src/features/files/files.controller.ts b/apps/server-asset-sg/src/features/files/files.controller.ts new file mode 100644 index 00000000..53adf9df --- /dev/null +++ b/apps/server-asset-sg/src/features/files/files.controller.ts @@ -0,0 +1,97 @@ +import { User } from '@asset-sg/shared/v2'; +import { AssetEditPolicy } from '@asset-sg/shared/v2'; +import { + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Param, + ParseIntPipe, + Post, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import * as E from 'fp-ts/Either'; +import { authorize } from '@/core/authorize'; +import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { PrismaService } from '@/core/prisma.service'; +import { AssetEditRepo } from '@/features/asset-edit/asset-edit.repo'; +import { AssetEditService } from '@/features/asset-edit/asset-edit.service'; +import { getFile } from '@/utils/file/get-file'; + +@Controller('/files') +export class FilesController { + constructor( + private readonly assetEditService: AssetEditService, + private readonly assetEditRepo: AssetEditRepo, + private readonly prismaService: PrismaService + ) {} + + @Get('/:id') + async download(@Res() res: Response, @Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { + const asset = await this.assetEditRepo.findByFile(id); + if (asset == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + authorize(AssetEditPolicy, user).canShow(asset); + + const result = await getFile(this.prismaService, id)(); + if (E.isLeft(result)) { + throw new HttpException(result.left.message, 500); + } + const file = result.right; + if (file.contentType) { + res.setHeader('Content-Type', file.contentType); + } + if (file.contentLength != null) { + res.setHeader('Content-Length', file.contentLength.toString()); + } + res.setHeader('Content-Disposition', `filename="${file.fileName}"`); + file.stream.pipe(res); + } + + @Post('/') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 250 * 1024 * 1024 } })) + async upload(@Req() req: Request, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: User) { + const assetId = parseInt((req.body as { assetId?: string }).assetId ?? ''); + if (isNaN(assetId)) { + throw new HttpException('missing assetId', HttpStatus.BAD_REQUEST); + } + const asset = await this.assetEditRepo.find(assetId); + if (asset == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + authorize(AssetEditPolicy, user).canUpdate(asset); + + const result = await this.assetEditService.uploadFile(user, asset.assetId, { + name: file.originalname, + buffer: file.buffer, + size: file.size, + mimetype: file.mimetype, + })(); + if (E.isLeft(result)) { + throw new HttpException(result.left.message, 500); + } + return result.right; + } + + @Delete('/:id') + async delete(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { + const asset = await this.assetEditRepo.findByFile(id); + if (asset == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + authorize(AssetEditPolicy, user).canDelete(asset); + + const e = await this.assetEditService.deleteFile(user, asset.assetId, id)(); + if (E.isLeft(e)) { + throw new HttpException(e.left.message, 500); + } + return e.right; + } +} diff --git a/apps/server-asset-sg/src/features/search/find-assets-by-polygon.ts b/apps/server-asset-sg/src/features/search/find-assets-by-polygon.ts deleted file mode 100644 index 7cb855f8..00000000 --- a/apps/server-asset-sg/src/features/search/find-assets-by-polygon.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { GetRightTypeOfTaskEither, unknownToError } from '@asset-sg/core'; -import { LV95 } from '@asset-sg/shared'; -import { PrismaClient } from '@prisma/client'; -import * as A from 'fp-ts/Array'; -import { pipe } from 'fp-ts/function'; -import { Eq as EqNumber } from 'fp-ts/number'; -import * as TE from 'fp-ts/TaskEither'; - -import { makeSearchAssetResult, searchAssetQuery } from './search-asset'; - -import { postgresStudiesByPolygon } from '@/utils/postgres-studies/postgres-studies'; - -const executeAssetQuery = (prismaClient: PrismaClient, assetIds: number[]) => - TE.tryCatch( - () => - prismaClient.asset.findMany({ - select: searchAssetQuery.select, - where: { assetId: { in: assetIds } }, - }), - unknownToError - ); - -export type AssetQueryResults = GetRightTypeOfTaskEither>; -export type AssetQueryResult = AssetQueryResults[number]; - -export function findAssetsByPolygon(prismaClient: PrismaClient, polygon: LV95[]) { - return pipe( - postgresStudiesByPolygon(prismaClient, polygon), - TE.bindTo('studies'), - TE.bind('assets', ({ studies }) => - executeAssetQuery( - prismaClient, - pipe( - studies, - A.map((s) => s.assetId), - A.uniq(EqNumber) - ) - ) - ), - TE.map(({ assets, studies }) => makeSearchAssetResult(assets, studies)) - ); -} diff --git a/apps/server-asset-sg/src/features/search/search-asset.ts b/apps/server-asset-sg/src/features/search/search-asset.ts deleted file mode 100644 index bdef988a..00000000 --- a/apps/server-asset-sg/src/features/search/search-asset.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - DateId, - DateIdOrd, - SearchAsset, - SearchAssetResult, - SearchAssetResultCodec, - dateIdFromDate, - makeUsageCode, -} from '@asset-sg/shared'; -import { Prisma } from '@prisma/client'; -import * as A from 'fp-ts/Array'; -import { flow, pipe } from 'fp-ts/function'; -import * as NEA from 'fp-ts/NonEmptyArray'; -import * as O from 'fp-ts/Option'; -import * as R from 'fp-ts/Record'; - -import type { AssetQueryResult, AssetQueryResults } from './find-assets-by-polygon'; - -import { PostgresAllStudies } from '@/utils/postgres-studies/postgres-studies'; - -const makeSearchAssets = ( - assetQueryResults: NEA.NonEmptyArray, - studies: PostgresAllStudies -): NEA.NonEmptyArray => { - const studiesMap = pipe( - studies, - NEA.groupBy((a) => a.assetId.toString()) - ); - return pipe( - assetQueryResults, - NEA.map((a): SearchAsset => { - const { createDate, manCatLabelRefs, internalUse, publicUse, assetLanguages, assetContacts, ...rest } = a; - return { - ...rest, - createDate: dateIdFromDate(createDate), - manCatLabelItemCodes: manCatLabelRefs.map((m) => m.manCatLabelItemCode), - usageCode: makeUsageCode(publicUse.isAvailable, internalUse.isAvailable), - languages: assetLanguages.map((a) => ({ code: a.languageItemCode })), - contacts: assetContacts.map((c) => ({ role: c.role, id: c.contactId })), - score: 1, - studies: pipe( - studiesMap, - R.lookup(a.assetId.toString()), - O.map((ss) => ss.map((s) => ({ studyId: s.studyId, geomText: s.geomText }))), - O.getOrElseW(() => []) - ), - }; - }) - ); -}; - -const makeSearchAssetResultNonEmpty = (assets: NEA.NonEmptyArray) => { - const orderedDates: NEA.NonEmptyArray = pipe( - assets, - NEA.map((a) => a.createDate), - NEA.uniq(DateIdOrd), - NEA.sort(DateIdOrd) - ); - - return SearchAssetResultCodec.encode({ - _tag: 'SearchAssetResultNonEmpty', - aggregations: { - ranges: { createDate: { min: NEA.head(orderedDates), max: NEA.last(orderedDates) } }, - buckets: { - authorIds: pipe( - assets, - A.map((a) => a.contacts.filter((c) => c.role === 'author').map((c) => c.id)), - A.flatten, - NEA.fromArray, - O.map( - flow( - NEA.groupBy((a) => String(a)), - R.map((g) => ({ key: NEA.head(g), count: g.length })), - R.toArray, - A.map(([, value]) => value) - ) - ), - O.getOrElseW(() => []) - ), - assetKindItemCodes: makeBuckets( - pipe( - assets, - A.map((a) => a.assetKindItemCode) - ) - ), - languageItemCodes: makeBuckets( - pipe( - assets, - A.map((a) => a.languages.map((l) => l.code)), - A.flatten - ) - ), - usageCodes: makeBuckets( - pipe( - assets, - A.map((a) => a.usageCode) - ) - ), - manCatLabelItemCodes: makeBuckets( - pipe( - assets, - A.map((a) => a.manCatLabelItemCodes), - A.flatten - ) - ), - }, - }, - assets, - }); -}; - -const makeBuckets = (codes: T[]) => - pipe( - codes, - NEA.fromArray, - O.map( - flow( - NEA.groupBy((a) => String(a)), - R.map((g) => ({ key: NEA.head(g), count: g.length })), - R.toArray, - A.map(([, value]) => value) - ) - ), - O.getOrElseW(() => []) - ); - -const makeSearchAssetResultFromStudiesNonEmpty = ( - assetQueryResults: NEA.NonEmptyArray, - studies: PostgresAllStudies -) => { - const assets = makeSearchAssets(assetQueryResults, studies); - return makeSearchAssetResultNonEmpty(assets); -}; - -export const makeSearchAssetResult = ( - assetQueryResults: AssetQueryResults, - studies: PostgresAllStudies -): SearchAssetResult => - pipe( - NEA.fromArray(assetQueryResults), - O.map((a) => makeSearchAssetResultFromStudiesNonEmpty(a, studies)), - O.getOrElse(() => SearchAssetResultCodec.encode({ _tag: 'SearchAssetResultEmpty' })) - ); - -const makeSearchAssetQuery = ( - args: Prisma.SelectSubset -) => args; - -export const searchAssetQuery = makeSearchAssetQuery({ - select: { - assetId: true, - titlePublic: true, - createDate: true, - assetKindItemCode: true, - assetFormatItemCode: true, - internalUse: { select: { isAvailable: true } }, - publicUse: { select: { isAvailable: true } }, - manCatLabelRefs: { select: { manCatLabelItemCode: true } }, - assetLanguages: { select: { languageItemCode: true } }, - assetContacts: { select: { role: true, contactId: true } }, - }, -}); diff --git a/apps/server-asset-sg/src/features/studies/studies.controller.ts b/apps/server-asset-sg/src/features/studies/studies.controller.ts index 8679c596..f9d8dda7 100644 --- a/apps/server-asset-sg/src/features/studies/studies.controller.ts +++ b/apps/server-asset-sg/src/features/studies/studies.controller.ts @@ -1,18 +1,19 @@ import { Readable } from 'stream'; +import { serializeStudyAsCsv, Study } from '@asset-sg/shared/v2'; +import { User } from '@asset-sg/shared/v2'; import { Controller, Get, Res } from '@nestjs/common'; import { Response } from 'express'; -import { RequireRole } from '@/core/decorators/require-role.decorator'; -import { serializeStudyAsCsv, Study } from '@/features/studies/study.model'; +import { Authorize } from '@/core/decorators/authorize.decorator'; +import { CurrentUser } from '@/core/decorators/current-user.decorator'; import { StudyRepo } from '@/features/studies/study.repo'; -import { Role } from '@/features/users/user.model'; @Controller('/studies') export class StudiesController { constructor(private readonly studyRepo: StudyRepo) {} @Get('/') - @RequireRole(Role.Viewer) - async list(@Res() res: Response): Promise { + @Authorize.User() + async list(@Res() res: Response, @CurrentUser() user: User): Promise { // This route loads all studies and encodes them as CSV. // CSV has been chosen as we have a large amount of studies (13'000+) // and need a concise format that can be processed in batches (which, for example, JSON can't). @@ -37,7 +38,11 @@ export class StudiesController { // The promise that is loading the next batch. // Note that this is running in parallel to the response writer. - let next: Promise | null = studyRepo.list({ limit: INITIAL_BATCH_SIZE, offset: 0 }); + let next: Promise | null = studyRepo.list({ + limit: INITIAL_BATCH_SIZE, + offset: 0, + workgroupIds: user.isAdmin ? null : [...user.roles.keys()], + }); // The maximal size of the next batch. let nextLimit = INITIAL_BATCH_SIZE; diff --git a/apps/server-asset-sg/src/features/studies/study.repo.ts b/apps/server-asset-sg/src/features/studies/study.repo.ts index e2f8e021..e9b6c14f 100644 --- a/apps/server-asset-sg/src/features/studies/study.repo.ts +++ b/apps/server-asset-sg/src/features/studies/study.repo.ts @@ -1,10 +1,10 @@ -import { LV95, LV95FromSpaceSeparatedString, parseLV95 } from '@asset-sg/shared'; +import { parseLV95 } from '@asset-sg/shared'; +import { Study, StudyId } from '@asset-sg/shared/v2'; +import { WorkgroupId } from '@asset-sg/shared/v2'; import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import * as E from 'fp-ts/Either'; import { PrismaService } from '@/core/prisma.service'; import { ReadRepo, RepoListOptions } from '@/core/repo'; -import { Study, StudyId } from '@/features/studies/study.model'; @Injectable() export class StudyRepo implements ReadRepo { @@ -19,38 +19,53 @@ export class StudyRepo implements ReadRepo { return result.length === 1 ? result[0] : null; } - list({ limit, offset, ids }: RepoListOptions = {}): Promise { + list({ limit, offset, ids, workgroupIds }: ListOptions = {}): Promise { + if (workgroupIds != null && workgroupIds.length === 0) { + return Promise.resolve([]); + } + const parts: Prisma.Sql[] = []; const conditions: Prisma.Sql[] = []; + if (workgroupIds != null) { + parts.push(Prisma.sql` + LEFT JOIN asset a ON a.asset_id = s.asset_id + `); + conditions.push(Prisma.sql` + a.workgroup_id IN (${Prisma.join(workgroupIds, ',')}) + `); + } if (ids != null && ids.length > 0) { conditions.push(Prisma.sql` - WHERE study_id IN (${Prisma.join(ids, ',')}) + s.study_id IN (${Prisma.join(ids, ',')}) `); } - conditions.push(Prisma.sql` - ORDER BY asset_id + if (conditions.length != 0) { + parts.push(Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`); + } + parts.push(Prisma.sql` + ORDER BY s.asset_id `); if (limit != null) { - conditions.push(Prisma.sql` + parts.push(Prisma.sql` LIMIT ${limit} `); } if (offset != null && offset !== 0) { - conditions.push(Prisma.sql` + parts.push(Prisma.sql` OFFSET ${offset} `); } - return this.query(Prisma.join(conditions, ' ')); + return this.query(Prisma.join(parts, ' ')); } private async query(condition: Prisma.Sql): Promise { type RawStudy = Omit & { center: string }; const studies: RawStudy[] = await this.prisma.$queryRaw` SELECT - study_id as "id", - asset_id AS "assetId", - is_point AS "isPoint", - SUBSTRING(centroid_geom_text FROM 7 FOR length(centroid_geom_text) -7) AS "center" - FROM public.all_study + s.study_id as "id", + s.asset_id AS "assetId", + s.is_point AS "isPoint", + SUBSTRING(s.centroid_geom_text FROM 7 FOR length(s.centroid_geom_text) -7) AS "center" + FROM public.all_study s ${condition} `; return studies.map((study) => { @@ -61,3 +76,7 @@ export class StudyRepo implements ReadRepo { }); } } + +interface ListOptions extends RepoListOptions { + workgroupIds?: WorkgroupId[] | null; +} diff --git a/apps/server-asset-sg/src/features/users/user.model.ts b/apps/server-asset-sg/src/features/users/user.model.ts deleted file mode 100644 index c04bf013..00000000 --- a/apps/server-asset-sg/src/features/users/user.model.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { IsEnum, IsString } from 'class-validator'; - -import { Data, Model } from '@/utils/data/model'; - -export interface User extends Model { - email: string; - role: Role; - lang: string; -} - -export type UserId = string; -export type UserData = Omit, 'email'>; - -export enum Role { - Admin = 'admin', - Editor = 'editor', - MasterEditor = 'master-editor', - Viewer = 'viewer', -} - -export class UserDataBoundary implements UserData { - @IsEnum(Role, { each: true }) - role!: Role; - - @IsString() - lang!: string; -} - -export const getRoleIndex = (role: Role): number => { - switch (role) { - case Role.Admin: - return 4; - case Role.Editor: - return 3; - case Role.MasterEditor: - return 2; - case Role.Viewer: - return 1; - } -}; diff --git a/apps/server-asset-sg/src/features/users/user.repo.ts b/apps/server-asset-sg/src/features/users/user.repo.ts index dc4fb4f9..e7bbf11f 100644 --- a/apps/server-asset-sg/src/features/users/user.repo.ts +++ b/apps/server-asset-sg/src/features/users/user.repo.ts @@ -1,9 +1,9 @@ +import { Role, User, UserData, UserId, WorkgroupId } from '@asset-sg/shared/v2'; import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '@/core/prisma.service'; import { Repo, RepoListOptions } from '@/core/repo'; -import { Role, User, UserData, UserId } from '@/features/users/user.model'; import { satisfy } from '@/utils/define'; import { handlePrismaMutationError } from '@/utils/prisma'; @@ -48,8 +48,14 @@ export class UserRepo implements Repo ({ workgroupId, role })), + skipDuplicates: true, + }, + }, }, select: userSelection, }); @@ -61,8 +67,17 @@ export class UserRepo implements Repo ({ workgroupId, role })), + skipDuplicates: true, + }, + }, }, select: userSelection, }); @@ -87,16 +102,31 @@ export class UserRepo implements Repo()({ +export const userSelection = satisfy()({ id: true, - role: true, email: true, lang: true, + isAdmin: true, + workgroups: { + select: { + workgroupId: true, + role: true, + }, + }, }); type SelectedUser = Prisma.AssetUserGetPayload<{ select: typeof userSelection }>; -const parse = (data: SelectedUser): User => ({ - ...data, - role: data.role as Role, -}); +const parse = (data: SelectedUser): User => { + const roles = new Map(); + for (const workgroup of data.workgroups) { + roles.set(workgroup.workgroupId, workgroup.role); + } + return { + id: data.id, + email: data.email, + isAdmin: data.isAdmin, + lang: data.lang, + roles, + }; +}; diff --git a/apps/server-asset-sg/src/features/users/users.controller.ts b/apps/server-asset-sg/src/features/users/users.controller.ts index a97827a2..b9e6190b 100644 --- a/apps/server-asset-sg/src/features/users/users.controller.ts +++ b/apps/server-asset-sg/src/features/users/users.controller.ts @@ -1,19 +1,9 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpException, - HttpStatus, - Param, - Put, - ValidationPipe, -} from '@nestjs/common'; - +import { convert, User, UserData, UserId, UserSchema } from '@asset-sg/shared/v2'; +import { UserDataSchema } from '@asset-sg/shared/v2'; +import { Controller, Delete, Get, HttpCode, HttpException, HttpStatus, Param, Put } from '@nestjs/common'; +import { Authorize } from '@/core/decorators/authorize.decorator'; import { CurrentUser } from '@/core/decorators/current-user.decorator'; -import { RequireRole } from '@/core/decorators/require-role.decorator'; -import { Role, User, UserDataBoundary, UserId } from '@/features/users/user.model'; +import { ParseBody } from '@/core/decorators/parse.decorator'; import { UserRepo } from '@/features/users/user.repo'; @Controller('/users') @@ -21,33 +11,44 @@ export class UsersController { constructor(private readonly userRepo: UserRepo) {} @Get('/current') - @RequireRole(Role.Viewer) - showCurrent(@CurrentUser() user: User): User { - return user; + showCurrent(@CurrentUser() user: User | null): User | null { + if (user == null) { + return null; + } + return convert(UserSchema, user); } @Get('/') - @RequireRole(Role.Admin) - list(): Promise { - return this.userRepo.list(); + @Authorize.Admin() + async list(): Promise { + return convert(UserSchema, await this.userRepo.list()); + } + + @Get('/:id') + async show(@Param('id') id: UserId): Promise { + const user = await this.userRepo.find(id); + if (user == null) { + return null; + } + return convert(UserSchema, user); } @Put('/:id') - @RequireRole(Role.Admin) + @Authorize.Admin() async update( @Param('id') id: UserId, - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - data: UserDataBoundary + @ParseBody(UserDataSchema) + data: UserData ): Promise { const user = await this.userRepo.update(id, data); - if (user === null) { + if (user == null) { throw new HttpException('not found', 404); } - return user; + return convert(UserSchema, user); } @Delete('/:id') - @RequireRole(Role.Admin) + @Authorize.Admin() @HttpCode(HttpStatus.NO_CONTENT) async delete(@Param('id') id: UserId): Promise { const isOk = await this.userRepo.delete(id); diff --git a/apps/server-asset-sg/src/features/users/users.http b/apps/server-asset-sg/src/features/users/users.http index c7aaca0d..9ddb7c62 100644 --- a/apps/server-asset-sg/src/features/users/users.http +++ b/apps/server-asset-sg/src/features/users/users.http @@ -8,6 +8,14 @@ Authorization: Impersonate {{user}} GET {{host}}/api/users Authorization: Impersonate {{user}} +### Find User by ID +GET {{host}}/api/users/{{user-id}} +Authorization: Impersonate {{user}} + +### Find User by ID +GET {{host}}/api/admin/user/{{user-id}} +Authorization: Impersonate {{user}} + ### Update user PUT {{host}}/api/users/{{user-id}} Authorization: Impersonate {{user}} @@ -15,5 +23,12 @@ Content-Type: application/json { "role": "admin", - "lang": "de" + "lang": "de", + "workgroups": [ + { + "workgroupId": 4, + "role": "MasterEditor" + } + ], + "isAdmin": false } diff --git a/apps/server-asset-sg/src/features/workgroups/workgroup-simple.repo.ts b/apps/server-asset-sg/src/features/workgroups/workgroup-simple.repo.ts new file mode 100644 index 00000000..4b1ba2bb --- /dev/null +++ b/apps/server-asset-sg/src/features/workgroups/workgroup-simple.repo.ts @@ -0,0 +1,53 @@ +import { User, UserId } from '@asset-sg/shared/v2'; +import { Role, SimpleWorkgroup, WorkgroupId } from '@asset-sg/shared/v2'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '@/core/prisma.service'; +import { ReadRepo, RepoListOptions } from '@/core/repo'; +import { satisfy } from '@/utils/define'; + +export class SimpleWorkgroupRepo implements ReadRepo { + constructor(private readonly prisma: PrismaService, private readonly user: User) {} + + async find(id: WorkgroupId): Promise { + const entry = await this.prisma.workgroup.findFirst({ + where: { id, users: this.user.isAdmin ? undefined : { some: { userId: this.user.id } } }, + select: simpleWorkgroupSelection(this.user.id), + }); + return entry == null ? null : parse(entry, this.user.isAdmin); + } + + async list({ limit, offset, ids }: RepoListOptions = {}): Promise { + const entries = await this.prisma.workgroup.findMany({ + where: { + id: ids == null ? undefined : { in: ids }, + users: this.user.isAdmin ? undefined : { some: { userId: this.user.id } }, + }, + take: limit, + skip: offset, + select: simpleWorkgroupSelection(this.user.id), + }); + return entries.map((it) => parse(it, this.user.isAdmin)); + } +} + +export const simpleWorkgroupSelection = (userId: UserId) => + satisfy()({ + id: true, + name: true, + users: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }); + +type SelectedWorkgroup = Prisma.WorkgroupGetPayload<{ select: ReturnType }>; + +const parse = (data: SelectedWorkgroup, isAdmin: boolean): SimpleWorkgroup => ({ + id: data.id, + name: data.name, + role: isAdmin ? Role.MasterEditor : data.users[0].role, +}); diff --git a/apps/server-asset-sg/src/features/workgroups/workgroup.repo.spec.ts b/apps/server-asset-sg/src/features/workgroups/workgroup.repo.spec.ts new file mode 100644 index 00000000..b89de4bc --- /dev/null +++ b/apps/server-asset-sg/src/features/workgroups/workgroup.repo.spec.ts @@ -0,0 +1,226 @@ +import { WorkgroupData, Role } from '@asset-sg/shared/v2'; +import { faker } from '@faker-js/faker'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { clearPrismaAssets, setupDB, setupDefaultWorkgroup } from '../../../../../test/setup-db'; +import { PrismaService } from '@/core/prisma.service'; +import { UserRepo } from '@/features/users/user.repo'; +import { WorkgroupRepo } from '@/features/workgroups/workgroup.repo'; + +describe('WorkgroupRepo', () => { + const prisma = new PrismaService(); + const repo = new WorkgroupRepo(prisma); + const userRepo = new UserRepo(prisma); + + beforeAll(async () => { + await setupDB(prisma); + }); + + beforeEach(async () => { + await clearPrismaAssets(prisma); + + // Delete the default workgroup. + await prisma.workgroup.deleteMany(); + }); + + describe('find', () => { + it('returns `null` when searching for a non-existent record', async () => { + // When + const workgroup = await repo.find(2); + + // Then + expect(workgroup).toBeNull(); + }); + it('returns the record associated with a specific id', async () => { + // Given + const data: WorkgroupData = { name: 'test', disabledAt: null, users: new Map() }; + const expected = await repo.create(data); + + // When + const actual = await repo.find(expected.id); + + // Then + expect(actual).not.toBeNull(); + expect(actual).toEqual(expected); + }); + }); + + describe('list', () => { + it('returns an empty list when no records exist', async () => { + // When + const workgroups = await repo.list({ limit: 100 }); + + // Then + expect(workgroups).toEqual([]); + }); + it('returns the specified amount of records', async () => { + // Given + const record1 = await repo.create({ name: 'Test 1', disabledAt: null, users: new Map() }); + const record2 = await repo.create({ name: 'Test 2', disabledAt: null, users: new Map() }); + const record3 = await repo.create({ name: 'Test 3', disabledAt: null, users: new Map() }); + await repo.create({ name: 'Test 4', disabledAt: null, users: new Map() }); + await repo.create({ name: 'Test 5', disabledAt: null, users: new Map() }); + + // When + const workgroups = await repo.list({ limit: 3 }); + + // Then + expect(workgroups).toHaveLength(3); + expect(workgroups).toEqual([record1, record2, record3]); + }); + it('returns the records appearing after the specified offset', async () => { + //Given + await repo.create({ name: 'Test 1', disabledAt: null, users: new Map() }); + await repo.create({ name: 'Test 2', disabledAt: null, users: new Map() }); + const record1 = await repo.create({ name: 'Test 3', disabledAt: null, users: new Map() }); + const record2 = await repo.create({ name: 'Test 4', disabledAt: null, users: new Map() }); + const record3 = await repo.create({ name: 'Test 5', disabledAt: null, users: new Map() }); + + // When + const workgroups = await repo.list({ limit: 3, offset: 2 }); + + // Then + expect(workgroups).toHaveLength(3); + expect(workgroups).toEqual([record1, record2, record3]); + }); + it('returns an empty list when offset is greater than the number of records', async () => { + //Given + await repo.create({ name: 'Test 1', disabledAt: null, users: new Map() }); + await repo.create({ name: 'Test 2', disabledAt: null, users: new Map() }); + await repo.create({ name: 'Test 3', disabledAt: null, users: new Map() }); + await repo.create({ name: 'Test 4', disabledAt: null, users: new Map() }); + await repo.create({ name: 'Test 5', disabledAt: null, users: new Map() }); + + // When + const workgroups = await repo.list({ limit: 3, offset: 5 }); + + // Then + expect(workgroups).toEqual([]); + }); + }); + + describe('create', () => { + it('creates a new record', async () => { + // Given + await setupDefaultWorkgroup(prisma); + const user = await userRepo.create({ + email: faker.internet.email(), + lang: 'de', + oidcId: faker.string.uuid(), + isAdmin: false, + roles: new Map(), + }); + const data: WorkgroupData = { + name: 'test', + disabledAt: null, + users: new Map([ + [ + user.id, + { + role: Role.MasterEditor, + email: user.email, + }, + ], + ]), + }; + + // When + const workgroup = await repo.create(data); + + // Then + expect(workgroup.name).toEqual(data.name); + expect(workgroup.disabledAt).toEqual(data.disabledAt); + expect(workgroup.users).toEqual(data.users); + }); + }); + + describe('update', () => { + it('returns `null` when updating a non-existent record', async () => { + //Given + const data: WorkgroupData = { name: 'test', disabledAt: null, users: new Map() }; + + // When + const workgroup = await repo.update(1, data); + + // Then + expect(workgroup).toBeNull(); + }); + it('updates an existing record', async () => { + //Given + await setupDefaultWorkgroup(prisma); + const user = await userRepo.create({ + email: faker.internet.email(), + lang: 'de', + oidcId: faker.string.uuid(), + isAdmin: false, + roles: new Map(), + }); + const initialWorkgroup: WorkgroupData = { name: 'test', disabledAt: null, users: new Map() }; + const workgroup = await repo.create(initialWorkgroup); + const data: WorkgroupData = { + name: 'new name', + disabledAt: new Date(), + users: new Map([ + [ + user.id, + { + role: Role.MasterEditor, + email: user.email, + }, + ], + ]), + }; + + //When + const updatedWorkgroup = await repo.update(workgroup.id, data); + + //Then + expect(updatedWorkgroup.name).toEqual(data.name); + expect(updatedWorkgroup.disabledAt).toEqual(data.disabledAt); + expect(updatedWorkgroup.users).toEqual(data.users); + }); + }); + describe('delete', () => { + it('returns `false` when deleting a non-existent record', async () => { + // When + const deleted = await repo.delete(2); + + // Then + expect(deleted).toBe(false); + }); + it('removes a record and its relations from the database', async () => { + //Given + await setupDefaultWorkgroup(prisma); + const user = await userRepo.create({ + email: faker.internet.email(), + lang: 'de', + oidcId: faker.string.uuid(), + isAdmin: false, + roles: new Map(), + }); + const data: WorkgroupData = { + name: 'test', + disabledAt: null, + users: new Map([ + [ + user.id, + { + role: Role.MasterEditor, + email: user.email, + }, + ], + ]), + }; + const workgroup = await repo.create(data); + + //When + const deleted = await repo.delete(workgroup.id); + expect(deleted).toBe(true); + + const assetCount = await prisma.asset.count({ where: { assetId: workgroup.id } }); + expect(assetCount).toBe(0); + + const userCount = await prisma.workgroupsOnUsers.count({ where: { workgroupId: workgroup.id } }); + expect(userCount).toBe(0); + }); + }); +}); diff --git a/apps/server-asset-sg/src/features/workgroups/workgroup.repo.ts b/apps/server-asset-sg/src/features/workgroups/workgroup.repo.ts new file mode 100644 index 00000000..11974ecc --- /dev/null +++ b/apps/server-asset-sg/src/features/workgroups/workgroup.repo.ts @@ -0,0 +1,148 @@ +import { User, UserId, UserOnWorkgroup, Workgroup, WorkgroupData, WorkgroupId } from '@asset-sg/shared/v2'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '@/core/prisma.service'; +import { Repo, RepoListOptions } from '@/core/repo'; +import { SimpleWorkgroupRepo } from '@/features/workgroups/workgroup-simple.repo'; +import { satisfy } from '@/utils/define'; +import { handlePrismaMutationError } from '@/utils/prisma'; + +@Injectable() +export class WorkgroupRepo implements Repo { + constructor(private readonly prisma: PrismaService) {} + + simple(user: User): SimpleWorkgroupRepo { + return new SimpleWorkgroupRepo(this.prisma, user); + } + + async find(id: WorkgroupId): Promise { + const entry = await this.prisma.workgroup.findUnique({ + where: { id }, + select: workgroupSelection, + }); + return entry == null ? null : parse(entry); + } + + async list({ limit, offset, ids }: RepoListOptions = {}): Promise { + const entries = await this.prisma.workgroup.findMany({ + where: ids == null ? undefined : { id: { in: ids } }, + take: limit, + skip: offset, + select: workgroupSelection, + }); + return entries.map(parse); + } + + async create(data: WorkgroupData): Promise { + const entry = await this.prisma.workgroup.create({ + data: { + name: data.name, + created_at: new Date(), + disabled_at: data.disabledAt, + users: data.users + ? { + createMany: { + data: [...data.users].map(([userId, user]) => ({ + userId, + role: user.role, + })), + skipDuplicates: true, + }, + } + : undefined, + }, + select: workgroupSelection, + }); + return parse(entry); + } + + async update(id: WorkgroupId, data: WorkgroupData): Promise { + try { + const entry = await this.prisma.workgroup.update({ + where: { id }, + data: { + name: data.name, + disabled_at: data.disabledAt, + users: data.users + ? { + deleteMany: { + userId: {}, + }, + createMany: { + data: [...data.users].map(([userId, user]) => ({ + userId: userId, + role: user.role, + })), + skipDuplicates: true, + }, + } + : undefined, + }, + select: workgroupSelection, + }); + return parse(entry); + } catch (e) { + return handlePrismaMutationError(e); + } + } + + async delete(id: WorkgroupId): Promise { + try { + await this.prisma.$transaction(async () => { + await this.prisma.workgroupsOnUsers.deleteMany({ where: { workgroupId: id } }); + await this.prisma.workgroup.delete({ where: { id } }); + }); + return true; + } catch (e) { + return handlePrismaMutationError(e) ?? false; + } + } +} + +export const workgroupSelection = satisfy()({ + id: true, + name: true, + disabled_at: true, + users: { + orderBy: { + user: { + email: 'asc', + }, + }, + select: { + role: true, + user: { + select: { + email: true, + id: true, + }, + }, + }, + }, + assets: { + orderBy: { + assetId: 'asc', + }, + select: { + assetId: true, + }, + }, +}); + +type SelectedWorkgroup = Prisma.WorkgroupGetPayload<{ select: typeof workgroupSelection }>; + +const parse = (data: SelectedWorkgroup): Workgroup => { + const users = new Map(); + for (const user of data.users) { + users.set(user.user.id, { + email: user.user.email, + role: user.role, + }); + } + return { + id: data.id, + name: data.name, + users, + disabledAt: data.disabled_at, + }; +}; diff --git a/apps/server-asset-sg/src/features/workgroups/workgroups.controller.ts b/apps/server-asset-sg/src/features/workgroups/workgroups.controller.ts new file mode 100644 index 00000000..8811c773 --- /dev/null +++ b/apps/server-asset-sg/src/features/workgroups/workgroups.controller.ts @@ -0,0 +1,109 @@ +import { + AssetId, + convert, + SimpleWorkgroup, + User, + Workgroup, + WorkgroupData, + WorkgroupDataSchema, + WorkgroupId, + WorkgroupPolicy, + WorkgroupSchema, +} from '@asset-sg/shared/v2'; +import { + Controller, + Delete, + Get, + HttpCode, + HttpException, + HttpStatus, + Param, + ParseIntPipe, + Post, + Put, + Query, +} from '@nestjs/common'; +import { Expose, Transform as TransformValue } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { authorize } from '@/core/authorize'; +import { Authorize } from '@/core/decorators/authorize.decorator'; +import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { ParseBody } from '@/core/decorators/parse.decorator'; +import { RepoListOptions } from '@/core/repo'; +import { WorkgroupRepo } from '@/features/workgroups/workgroup.repo'; + +class ListQuery { + @Expose({ name: 'simple' }) + @TransformValue(({ value }) => value != null && value !== 'false', { toClassOnly: true }) + @IsBoolean() + isSimple!: boolean; +} + +@Controller('/workgroups') +export class WorkgroupsController { + constructor(private readonly workgroupRepo: WorkgroupRepo) {} + + @Get('/') + @Authorize.User() + async list(@CurrentUser() user: User, @Query() query: ListQuery): Promise { + const options: RepoListOptions = { + ids: user.isAdmin ? undefined : [...user.roles.keys()], + }; + if (query.isSimple) { + return this.workgroupRepo.simple(user).list(options); + } + return convert(WorkgroupSchema, await this.workgroupRepo.list(options)); + } + + @Get('/:id') + async show(@Param('id', ParseIntPipe) id: AssetId, @CurrentUser() user: User): Promise { + const record = await this.workgroupRepo.find(id); + if (record === null) { + throw new HttpException('not found', 404); + } + authorize(WorkgroupPolicy, user).canShow(record); + return convert(WorkgroupSchema, record); + } + + @Post('/') + @HttpCode(HttpStatus.CREATED) + async create( + @ParseBody(WorkgroupDataSchema) + data: WorkgroupData, + @CurrentUser() user: User + ): Promise { + authorize(WorkgroupPolicy, user).canCreate(); + const record = await this.workgroupRepo.create(data); + return convert(WorkgroupSchema, record); + } + + @Put('/:id') + async update( + @Param('id', ParseIntPipe) id: number, + @ParseBody(WorkgroupDataSchema) + data: WorkgroupData, + @CurrentUser() user: User + ): Promise { + const record = await this.workgroupRepo.find(id); + if (record === null) { + throw new HttpException('not found', 404); + } + authorize(WorkgroupPolicy, user).canUpdate(record); + const workgroup = await this.workgroupRepo.update(record.id, data); + if (workgroup === null) { + throw new HttpException('not found', 404); + } + return convert(WorkgroupSchema, workgroup); + } + + @Delete('/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User): Promise { + const record = await this.workgroupRepo.find(id); + if (record == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + authorize(WorkgroupPolicy, user).canDelete(record); + await this.workgroupRepo.delete(record.id); + } +} diff --git a/apps/server-asset-sg/src/features/workgroups/workgroups.http b/apps/server-asset-sg/src/features/workgroups/workgroups.http new file mode 100644 index 00000000..be97405b --- /dev/null +++ b/apps/server-asset-sg/src/features/workgroups/workgroups.http @@ -0,0 +1,72 @@ +@workgroupId = 5 + +### Show specific workgroup +GET {{host}}/api/workgroups/{{workgroupId}} +Authorization: Impersonate {{user}} + +### List workgroups +GET {{host}}/api/workgroups +Authorization: Impersonate {{user}} + +### List simple workgroups +GET {{host}}/api/workgroups?simple +Authorization: Impersonate {{user}} + + +### Create workgroup +POST {{host}}/api/workgroups +Authorization: Impersonate {{user}} +Content-Type: application/json + +{ + "name": "WORKGROUP", + "disabled_at": null, + "assets": [ + { + "assetId": 12 + }, + { + "assetId": 13 + } + ], + "users": [ + { + "userId": "379a20e6-6a5d-4390-93ca-d408613e854d", + "role": "MasterEditor" + } + ] +} + +### Update workgroup +PUT {{host}}/api/workgroups/{{workgroupId}} +Authorization: Impersonate {{user}} +Content-Type: application/json + +{ + "name": "Testing5", + "disabled_at": null, + "assets": [ + { + "assetId": 5 + }, + { + "assetId": 13 + }, + { + "assetId": 18 + }, + { + "assetId": 21 + } + ], + "users": [ + { + "userId": "379a20e6-6a5d-4390-93ca-d408613e854d", + "role": "Editor" + } + ] +} + +### DELETE user +DELETE {{host}}/api/workgroups/{{workgroupId}} +Authorization: Impersonate {{user}} diff --git a/apps/server-asset-sg/src/main.ts b/apps/server-asset-sg/src/main.ts index 364783ef..db70eb6c 100644 --- a/apps/server-asset-sg/src/main.ts +++ b/apps/server-asset-sg/src/main.ts @@ -1,4 +1,4 @@ -import { Logger } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; @@ -12,6 +12,7 @@ const API_PORT = process.env.PORT || 3333; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); app.setGlobalPrefix(API_PREFIX); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })); await app.listen(API_PORT); Logger.log(`🚀 application is running on: http://localhost:${API_PORT}/${API_PREFIX}`); } diff --git a/apps/server-asset-sg/src/models/asset-edit-detail.ts b/apps/server-asset-sg/src/models/asset-edit-detail.ts index c1a5bad9..70ac607e 100644 --- a/apps/server-asset-sg/src/models/asset-edit-detail.ts +++ b/apps/server-asset-sg/src/models/asset-edit-detail.ts @@ -93,6 +93,7 @@ export const AssetEditDetailFromPostgres = pipe( ), D.map((a) => a.map((b) => b.file)) ), + workgroupId: D.number, studies: PostgresAllStudies, }) ); diff --git a/apps/server-asset-sg/src/models/jwt-request.ts b/apps/server-asset-sg/src/models/jwt-request.ts index 775f52f0..64e81876 100644 --- a/apps/server-asset-sg/src/models/jwt-request.ts +++ b/apps/server-asset-sg/src/models/jwt-request.ts @@ -1,10 +1,17 @@ +import { User } from '@asset-sg/shared/v2'; +import { Policy } from '@asset-sg/shared/v2'; import { Request } from 'express'; import * as jwt from 'jsonwebtoken'; -import { User } from '@/features/users/user.model'; - export interface JwtRequest extends Request { user: User; accessToken: string; jwtPayload: jwt.JwtPayload; } + +export interface AuthorizedRequest extends Request { + authorized: { + record: object | null; + policy: Policy; + }; +} diff --git a/apps/server-asset-sg/tsconfig.app.json b/apps/server-asset-sg/tsconfig.app.json index 598c9bb2..9e25d0d3 100644 --- a/apps/server-asset-sg/tsconfig.app.json +++ b/apps/server-asset-sg/tsconfig.app.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["node"], + "types": ["node", "multer"], "emitDecoratorMetadata": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, diff --git a/apps/server-asset-sg/tsconfig.json b/apps/server-asset-sg/tsconfig.json index 9ee0f6b7..093e49da 100644 --- a/apps/server-asset-sg/tsconfig.json +++ b/apps/server-asset-sg/tsconfig.json @@ -24,6 +24,7 @@ "@asset-sg/favourite": ["../../libs/favourite/src/index.ts"], "@asset-sg/profile": ["../../libs/profile/src/index.ts"], "@asset-sg/shared": ["../../libs/shared/src/index.ts"], + "@asset-sg/shared/v2": ["../../libs/shared/v2/src/index.ts"], "ngx-kobalte": ["../../libs/ngx-kobalte/src/index.ts"] } } diff --git a/apps/server-asset-sg/tsconfig.spec.json b/apps/server-asset-sg/tsconfig.spec.json index d5f7d408..1ef29a9f 100644 --- a/apps/server-asset-sg/tsconfig.spec.json +++ b/apps/server-asset-sg/tsconfig.spec.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["jest", "node"], + "types": ["jest", "node", "multer"], "resolveJsonModule": true, "esModuleInterop": true }, diff --git a/development/init/oidc/oidc-mock-users.json b/development/init/oidc/oidc-mock-users.json index 04e58e02..df137ad2 100644 --- a/development/init/oidc/oidc-mock-users.json +++ b/development/init/oidc/oidc-mock-users.json @@ -73,7 +73,7 @@ }, { "Type": "cognito:groups", - "Value": "[\"assets.swissgeol2\"]", + "Value": "[\"assets.swissgeol\"]", "ValueType": "json" }, { @@ -82,5 +82,89 @@ "ValueType": "string" } ] + }, + { + "SubjectId": "e06ad465-3adc-4ad7-bee5-ff0605a4b929", + "Username": "editor", + "Password": "editor", + "Claims": [ + { + "Type": "name", + "Value": "Editor", + "ValueType": "string" + }, + { + "Type": "family_name", + "Value": "Editor", + "ValueType": "string" + }, + { + "Type": "given_name", + "Value": "Editor", + "ValueType": "string" + }, + { + "Type": "email", + "Value": "editor@assets.swissgeol.ch", + "ValueType": "string" + }, + { + "Type": "email_verified", + "Value": "true", + "ValueType": "boolean" + }, + { + "Type": "cognito:groups", + "Value": "[\"assets.swissgeol\"]", + "ValueType": "json" + }, + { + "Type": "username", + "Value": "3_editor@assets.swissgeol.ch", + "ValueType": "string" + } + ] + }, + { + "SubjectId": "e06ad465-3adc-4ad7-bee5-ff0605a4b926", + "Username": "master-editor", + "Password": "master-editor", + "Claims": [ + { + "Type": "name", + "Value": "master-editor", + "ValueType": "string" + }, + { + "Type": "family_name", + "Value": "master-editor", + "ValueType": "string" + }, + { + "Type": "given_name", + "Value": "master-editor", + "ValueType": "string" + }, + { + "Type": "email", + "Value": "master-editor@assets.swissgeol.ch", + "ValueType": "string" + }, + { + "Type": "email_verified", + "Value": "true", + "ValueType": "boolean" + }, + { + "Type": "cognito:groups", + "Value": "[\"assets.swissgeol\"]", + "ValueType": "json" + }, + { + "Type": "username", + "Value": "4_master-editor@assets.swissgeol.ch", + "ValueType": "string" + } + ] } ] diff --git a/jest.config.ts b/jest.config.ts index d0dbd1b8..304f68c0 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,5 +1,7 @@ import { getJestProjects } from '@nx/jest'; +import { Config } from 'jest'; -export default { - projects: getJestProjects(), +const config: Config = { + projects: [...getJestProjects(), 'lib/shared/v2'], }; +export default config; diff --git a/libs/admin/src/lib/admin-routing.module.ts b/libs/admin/src/lib/admin-routing.module.ts new file mode 100644 index 00000000..a412d91f --- /dev/null +++ b/libs/admin/src/lib/admin-routing.module.ts @@ -0,0 +1,48 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AdminPageComponent } from './components/admin-page'; +import { UserEditComponent } from './components/user-edit/user-edit.component'; +import { UsersComponent } from './components/users/users.component'; +import { WorkgroupEditComponent } from './components/workgroup-edit/workgroup-edit.component'; +import { WorkgroupsComponent } from './components/workgroups/workgroups.component'; + +const routes: Routes = [ + { + path: '', + component: AdminPageComponent, + children: [ + { + path: 'users', + component: UsersComponent, + }, + { + path: 'workgroups', + component: WorkgroupsComponent, + }, + { + path: 'workgroups/new', + component: WorkgroupEditComponent, + }, + { + path: 'users/:id', + component: UserEditComponent, + }, + { + path: 'workgroups/:id', + component: WorkgroupEditComponent, + }, + { + pathMatch: 'full', + path: '', + redirectTo: 'users', + }, + ], + }, +]; + +@NgModule({ + declarations: [], + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class AdminPageRoutingModule {} diff --git a/libs/admin/src/lib/admin.module.ts b/libs/admin/src/lib/admin.module.ts index 499d0539..8b39deb5 100644 --- a/libs/admin/src/lib/admin.module.ts +++ b/libs/admin/src/lib/admin.module.ts @@ -3,11 +3,30 @@ import { DialogModule } from '@angular/cdk/dialog'; import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatCard } from '@angular/material/card'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialogActions, MatDialogContent, MatDialogTitle } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; -import { RouterModule } from '@angular/router'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, + MatHeaderRowDef, + MatRow, + MatRowDef, + MatTable, +} from '@angular/material/table'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltip } from '@angular/material/tooltip'; import { AnchorComponent, ButtonComponent, @@ -16,25 +35,37 @@ import { ViewChildMarker, } from '@asset-sg/client-shared'; import { SvgIconComponent } from '@ngneat/svg-icon'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { ForModule } from '@rx-angular/template/for'; +import { IfModule } from '@rx-angular/template/if'; import { LetModule } from '@rx-angular/template/let'; import { PushModule } from '@rx-angular/template/push'; - +import { AdminPageRoutingModule } from './admin-routing.module'; +import { AddWorkgroupUserDialogComponent } from './components/add-workgroup-user-dialog/add-workgroup-user-dialog.component'; import { AdminPageComponent } from './components/admin-page'; -import { UserCollapsedComponent } from './components/user-collapsed'; -import { UserExpandedComponent } from './components/user-expanded'; +import { UserEditComponent } from './components/user-edit/user-edit.component'; +import { UsersComponent } from './components/users/users.component'; +import { WorkgroupEditComponent } from './components/workgroup-edit/workgroup-edit.component'; +import { WorkgroupsComponent } from './components/workgroups/workgroups.component'; +import { AdminEffects } from './state/admin.effects'; +import { adminReducer } from './state/admin.reducer'; @NgModule({ - declarations: [AdminPageComponent, UserCollapsedComponent, UserExpandedComponent], + declarations: [ + AdminPageComponent, + WorkgroupsComponent, + WorkgroupEditComponent, + UsersComponent, + UserEditComponent, + AddWorkgroupUserDialogComponent, + ], imports: [ CommonModule, - RouterModule.forChild([ - { - path: '', - component: AdminPageComponent, - }, - ]), + AdminPageRoutingModule, + StoreModule.forFeature('admin', adminReducer), + EffectsModule.forFeature(AdminEffects), TranslateModule.forChild(), ReactiveFormsModule, @@ -49,11 +80,33 @@ import { UserExpandedComponent } from './components/user-expanded'; A11yModule, MatProgressBarModule, + MatTooltip, + MatCheckbox, + MatFormFieldModule, + MatInputModule, + MatTable, + MatColumnDef, + MatHeaderCell, + MatCell, + MatCellDef, + MatHeaderCellDef, + MatHeaderRow, + MatRow, + MatHeaderRowDef, + MatRowDef, + MatTabsModule, ViewChildMarker, ButtonComponent, AnchorComponent, DrawerComponent, DrawerPanelComponent, + MatDialogActions, + MatDialogContent, + MatDialogTitle, + MatAutocompleteModule, + MatCard, + MatSlideToggle, + IfModule, ], }) export class AdminModule {} diff --git a/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.html b/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.html new file mode 100644 index 00000000..34ac32c5 --- /dev/null +++ b/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.html @@ -0,0 +1,34 @@ +

    +
    +

    admin.workgroupPage.addUsers

    +
    +
    +
    admin.users
    +
    + + admin.users + + + {{ user.email }} + + + @if (formGroup.controls['users'].invalid) { + Add at least one user + } + +
    admin.role
    + + admin.role + + + {{ role }} + + + +
    +
    +
    + + +
    +
    diff --git a/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.scss b/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.scss new file mode 100644 index 00000000..e78ba581 --- /dev/null +++ b/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.scss @@ -0,0 +1,13 @@ +.header { + padding: 20px 24px 0 24px; +} + +.select { + width: 100%; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 1rem; +} diff --git a/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.ts b/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.ts new file mode 100644 index 00000000..3054d805 --- /dev/null +++ b/libs/admin/src/lib/components/add-workgroup-user-dialog/add-workgroup-user-dialog.component.ts @@ -0,0 +1,85 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { User, UserId, Workgroup, WorkgroupData } from '@asset-sg/shared/v2'; +import { Store } from '@ngrx/store'; +import { Role } from '@prisma/client'; +import { Observable, Subscription } from 'rxjs'; +import * as actions from '../../state/admin.actions'; +import { AppStateWithAdmin } from '../../state/admin.reducer'; +import { selectUsers } from '../../state/admin.selector'; +import { Mode } from '../workgroup-edit/workgroup-edit.component'; + +@Component({ + selector: 'asset-sg-add-workgroup-user-dialog', + templateUrl: './add-workgroup-user-dialog.component.html', + styleUrls: ['./add-workgroup-user-dialog.component.scss'], +}) +export class AddWorkgroupUserDialogComponent implements OnInit { + public formGroup = new FormGroup({ + users: new FormControl([], { validators: [Validators.required], nonNullable: true }), + role: new FormControl(Role.Viewer, { validators: [Validators.required], nonNullable: true }), + }); + + public readonly roles: Role[] = Object.values(Role); + + public users: User[] = []; + public workgroup: Workgroup; + public mode: Mode; + + private readonly users$: Observable = this.store.select(selectUsers); + private readonly subscriptions: Subscription = new Subscription(); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: { workgroup: Workgroup; mode: Mode }, + private readonly dialogRef: MatDialogRef, + private store: Store + ) { + this.workgroup = this.data.workgroup; + this.mode = this.data.mode; + } + + public ngOnInit() { + this.initSubscriptions(); + } + + public close() { + this.dialogRef.close(); + } + + public addUsers() { + if (this.formGroup.invalid) { + return; + } + const users = new Map(this.workgroup.users); + const newUserIds = new Set(this.formGroup.controls.users.value); + for (const user of this.users) { + if (!newUserIds.has(user.id)) { + continue; + } + users.set(user.id, { + email: user.email, + role: this.formGroup.controls['role'].value, + }); + } + const workgroup: WorkgroupData = { + name: this.workgroup.name, + disabledAt: this.workgroup.disabledAt, + users, + }; + if (this.mode === 'edit') { + this.store.dispatch(actions.updateWorkgroup({ workgroupId: this.workgroup.id, workgroup })); + } else { + this.store.dispatch(actions.setWorkgroup({ workgroup: { ...workgroup, id: -1 } })); + } + this.dialogRef.close(); + } + + public isUserInWorkgroup(userId: string): boolean { + return this.workgroup.users.has(userId); + } + + private initSubscriptions() { + this.subscriptions.add(this.users$.subscribe((users) => (this.users = users))); + } +} diff --git a/libs/admin/src/lib/components/admin-page/admin-page.component.html b/libs/admin/src/lib/components/admin-page/admin-page.component.html index 48c21063..723e59ea 100644 --- a/libs/admin/src/lib/components/admin-page/admin-page.component.html +++ b/libs/admin/src/lib/components/admin-page/admin-page.component.html @@ -1,33 +1,21 @@ - - - -
    error!
    -
    - - - -
    - - - - - - -
    -
    -
    -
    -
    -
    + +
    + @if (!isDetailPage) { + + } @else { + + } +
    + +
    +
    diff --git a/libs/admin/src/lib/components/admin-page/admin-page.component.scss b/libs/admin/src/lib/components/admin-page/admin-page.component.scss index f7b48697..98a61460 100644 --- a/libs/admin/src/lib/components/admin-page/admin-page.component.scss +++ b/libs/admin/src/lib/components/admin-page/admin-page.component.scss @@ -1,32 +1,59 @@ @use "../../styles/variables"; +::ng-deep .navigation .navigation__link { + .mdc-tab__text-label { + color: variables.$cyan-09; + } + + .mdc-tab__text-label:hover { + color: variables.$cyan-09; + } +} + :host { background-color: variables.$grey-03; position: relative; + height: calc(100vh - 88px); + width: 100%; + padding: 1rem; } -[asset-sg-primary].create-new-user-button { - margin-top: 1rem; - margin-bottom: 1rem; +.loading-bar { + position: absolute; + inset: 0; + z-index: 100; + background-color: variables.$grey-03; } -.drawer-panel-content { - padding: 0rem 1rem 1rem 0; - position: relative; +.admin-page { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; } -mat-progress-bar { - position: absolute; - top: 1px; - width: calc(100% - 1rem); +.content { + flex-grow: 1; + overflow-y: auto; } -.user-list { - height: calc(100% - 4.5rem); - overflow-y: scroll; -} +.navigation { + display: flex; + gap: 1rem; + padding: 0 1rem; + font-size: 16px; + + .navigation__link { + color: variables.$cyan-09; + text-decoration: underline; + + &--active { + text-decoration: none; + color: black; + } + } -asset-sg-user-collapsed, -asset-sg-user-expanded { - margin-bottom: 1rem; + .pointer { + cursor: pointer; + } } diff --git a/libs/admin/src/lib/components/admin-page/admin-page.component.ts b/libs/admin/src/lib/components/admin-page/admin-page.component.ts index 512f7c7e..b395ea74 100644 --- a/libs/admin/src/lib/components/admin-page/admin-page.component.ts +++ b/libs/admin/src/lib/components/admin-page/admin-page.component.ts @@ -1,77 +1,44 @@ -import { TemplatePortal } from '@angular/cdk/portal'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - TemplateRef, - ViewChild, - ViewContainerRef, - inject, -} from '@angular/core'; - -import { - AppPortalService, - AppState, - LifecycleHooks, - LifecycleHooksDirective, - fromAppShared, -} from '@asset-sg/client-shared'; -import { ORD, rdIsNotComplete } from '@asset-sg/core'; -import { User } from '@asset-sg/shared'; +import { Component, inject, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { AppPortalService, AppState, CURRENT_LANG, LifecycleHooksDirective } from '@asset-sg/client-shared'; import { Store } from '@ngrx/store'; -import { asyncScheduler, observeOn, takeWhile } from 'rxjs'; - -import { AdminService } from '../../services/admin.service'; -import { UserExpandedOutput } from '../user-expanded'; - -import { AdminPageStateMachine } from './admin-page.state-machine'; +import * as actions from '../../state/admin.actions'; +import { selectIsLoading } from '../../state/admin.selector'; @Component({ selector: 'asset-sg-admin', templateUrl: './admin-page.component.html', styleUrls: ['./admin-page.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [LifecycleHooksDirective], }) -export class AdminPageComponent { +export class AdminPageComponent implements OnInit { @ViewChild('templateDrawerPortalContent') templateDrawerPortalContent!: TemplateRef; - private _lc = inject(LifecycleHooks); - private _appPortalService = inject(AppPortalService); - private _viewContainerRef = inject(ViewContainerRef); - private _cd = inject(ChangeDetectorRef); - private _adminService = inject(AdminService); - private _store = inject(Store); - - public sm = new AdminPageStateMachine({ - rdUserId$: this._store.select(fromAppShared.selectRDUserProfile).pipe( - ORD.map((u) => u.id), - takeWhile(rdIsNotComplete, true) - ), - getUsers: () => this._adminService.getUsers(), - updateUser: (user) => this._adminService.updateUser(user), - deleteUser: (id) => this._adminService.deleteUser(id), - }); - - constructor() { - this._lc.afterViewInit$.pipe(observeOn(asyncScheduler)).subscribe(() => { - this._appPortalService.setAppBarPortalContent(null); - this._appPortalService.setDrawerPortalContent( - new TemplatePortal(this.templateDrawerPortalContent, this._viewContainerRef) - ); - this._cd.detectChanges(); - }); + private readonly store = inject(Store); + public readonly isLoading$ = this.store.select(selectIsLoading); + public readonly currentLang$ = inject(CURRENT_LANG); + private readonly appPortalService = inject(AppPortalService); + private readonly router = inject(Router); + + ngOnInit(): void { + this.store.dispatch(actions.listWorkgroups()); + this.store.dispatch(actions.listUsers()); + this.appPortalService.setAppBarPortalContent(null); + this.appPortalService.setDrawerPortalContent(null); } - public handleUserExpandedOutput(output: UserExpandedOutput): void { - UserExpandedOutput.match({ - userEdited: (user) => this.sm.saveEditedUser(user), - userExpandCanceled: () => this.sm.cancelEditOrSave(), - userDelete: (user) => this.sm.deleteUser(user.id), - })(output); + public get isDetailPage(): boolean { + return ( + this.router.url.includes('/workgroups/') || + this.router.url.includes('/users/') || + this.router.url.includes('/new') + ); } - public trackByFn(_: number, item: User): string { - return item.id; + getBackPath(lang: string): string[] { + if (this.router.url.includes('/workgroups/')) { + return [`/${lang}/admin/workgroups`]; + } + return [`/${lang}/admin/users`]; } } diff --git a/libs/admin/src/lib/components/admin-page/admin-page.state-machine.ts b/libs/admin/src/lib/components/admin-page/admin-page.state-machine.ts deleted file mode 100644 index 696753aa..00000000 --- a/libs/admin/src/lib/components/admin-page/admin-page.state-machine.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { ApiError } from '@asset-sg/client-shared'; -import { ORD } from '@asset-sg/core'; -import { User, Users, byEmail } from '@asset-sg/shared'; -import * as RD from '@devexperts/remote-data-ts'; -import { makeADT, ofType } from '@morphic-ts/adt'; -import * as A from 'fp-ts/Array'; -import { ReplaySubject, forkJoin, map } from 'rxjs'; - -type AdminPageState = Loading | StateApiError | ReadMode | EditMode | CreateMode; - -export class AdminPageStateMachine { - private __state: AdminPageState = AdminPageState.of.loading({ showProgressBar: true }); - - private set _state(state: AdminPageState) { - this.__state = state; - this.state$.next(state); - } - - private get _state() { - return this.__state; - } - - public state$ = new ReplaySubject(); - - public readonly stateIs = AdminPageState.is; - public readonly stateIsAnyOf = AdminPageState.isAnyOf; - - constructor( - private effects: { - rdUserId$: ORD.ObservableRemoteData; - getUsers: () => ORD.ObservableRemoteData; - updateUser: (user: User) => ORD.ObservableRemoteData; - deleteUser: (id: string) => ORD.ObservableRemoteData; - } - ) { - const { getUsers: _getUsers } = this.effects; - this.effects = { ...this.effects, getUsers: () => _getUsers().pipe(ORD.map(A.sort(byEmail))) }; - this._load(); - } - - private _load() { - this._state = AdminPageState.of.loading({ showProgressBar: true }); - - forkJoin([this.effects.rdUserId$, this.effects.getUsers()]) - .pipe(map(([rdUserId, rdUsers]) => RD.combine(rdUserId, rdUsers))) - .subscribe((rd) => { - if (RD.isSuccess(rd)) { - const [userId, users] = rd.value; - this._state = this.createReadMode(userId, users); - } else if (RD.isFailure(rd)) { - this._state = AdminPageState.of.stateApiError({ showProgressBar: false, error: rd.error }); - } - }); - } - - private createReadMode(userId: string, users: Users) { - return AdminPageState.of.readMode({ - showProgressBar: false, - _userId: userId, - _users: users, - usersVM: users.map((u) => ({ ...u, expanded: false, disableEdit: false })), - }); - } - - public editUser(userId: string) { - if (AdminPageState.is.readMode(this._state)) { - this._state = AdminPageState.of.editMode({ - ...this._state, - currentEditedUserId: userId, - usersVM: this._state.usersVM.map((u) => ({ - ...u, - expanded: u.id === userId, - disableEdit: u.id !== userId, - })), - }); - } - } - - public cancelEditOrSave() { - if (AdminPageState.isAnyOf(['editMode', 'createMode'])(this._state)) { - this._state = this.createReadMode(this._state._userId, this._state._users); - } - } - - public saveEditedUser(user: User) { - if (AdminPageState.is.editMode(this._state)) { - this._state = AdminPageState.as.editMode({ ...this._state, showProgressBar: true }); - this.modifyAndReload(this.effects.updateUser(user), this._state._userId); - } - } - - public deleteUser(id: string) { - if (AdminPageState.is.editMode(this._state)) { - this._state = AdminPageState.as.editMode({ ...this._state, showProgressBar: true }); - this.modifyAndReload(this.effects.deleteUser(id), this._state._userId); - } - } - - private modifyAndReload(modify$: ORD.ObservableRemoteData, userId: string) { - modify$ - .pipe( - ORD.filterIsComplete, - ORD.chainSwitchMapW(() => this.effects.getUsers()) - ) - .subscribe((rd) => { - if (RD.isSuccess(rd)) { - this._state = this.createReadMode(userId, rd.value); - } else if (RD.isFailure(rd)) { - this._state = AdminPageState.of.stateApiError({ showProgressBar: false, error: rd.error }); - } - }); - } - - public reset() { - this._load(); - } -} - -interface UserVM extends User { - expanded: boolean; - disableEdit: boolean; -} - -interface WithDataLoaded { - showProgressBar: boolean; - _userId: string; - _users: Users; - usersVM: UserVM[]; -} - -interface Loading { - _tag: 'loading'; - showProgressBar: true; -} - -interface StateApiError { - _tag: 'stateApiError'; - showProgressBar: false; - error: ApiError; -} - -interface ReadMode extends WithDataLoaded { - _tag: 'readMode'; - showProgressBar: false; -} - -interface EditMode extends WithDataLoaded { - _tag: 'editMode'; - showProgressBar: boolean; - currentEditedUserId: string; -} - -interface CreateMode extends WithDataLoaded { - _tag: 'createMode'; - showProgressBar: boolean; -} - -const AdminPageState = makeADT('_tag')({ - loading: ofType(), - readMode: ofType(), - editMode: ofType(), - createMode: ofType(), - stateApiError: ofType(), -}); diff --git a/libs/admin/src/lib/components/user-collapsed/index.ts b/libs/admin/src/lib/components/user-collapsed/index.ts deleted file mode 100644 index b5c9a75b..00000000 --- a/libs/admin/src/lib/components/user-collapsed/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './user-collapsed.component'; diff --git a/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.html b/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.html deleted file mode 100644 index d1074a28..00000000 --- a/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.scss b/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.scss deleted file mode 100644 index 5613b5d8..00000000 --- a/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.scss +++ /dev/null @@ -1,14 +0,0 @@ -@use "../../styles/variables"; - -:host { - display: flex; - align-items: center; - justify-content: space-between; - background-color: variables.$white; - padding: 0.5rem 1rem; -} - -.email { - color: variables.$cyan-09; - font-weight: variables.$font-bold; -} diff --git a/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.ts b/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.ts deleted file mode 100644 index 3005a87a..00000000 --- a/libs/admin/src/lib/components/user-collapsed/user-collapsed.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; - -import { User } from '@asset-sg/shared'; - -@Component({ - selector: 'asset-sg-user-collapsed', - templateUrl: './user-collapsed.component.html', - styleUrls: ['./user-collapsed.component.scss'], -}) -export class UserCollapsedComponent { - @Input() public user?: User; - @Input() public disableEdit = false; - @Output() public editClicked = new EventEmitter(); -} diff --git a/libs/admin/src/lib/components/user-edit/user-edit.component.html b/libs/admin/src/lib/components/user-edit/user-edit.component.html new file mode 100644 index 00000000..bf3467b4 --- /dev/null +++ b/libs/admin/src/lib/components/user-edit/user-edit.component.html @@ -0,0 +1,102 @@ + +
    +
    +
    + +
    +
    +
    + + admin.userPage.lang + + admin.languages.de + admin.languages.en + admin.languages.fr + admin.languages.it + admin.languages.rm + + +
    +
    + + admin.userPage.admin + +
    +
    +
    +
    +

    admin.workgroups

    + + + admin.userPage.addWorkgroups + + + + + {{ workgroup.name }} + + + + +
    +
    + + + + + + + + + + + + + + + + + + + + +
    Name{{ workgroup.name }}admin.role + + admin.role + + + {{ role }} + + + + admin.actions + +
    +
    +
    + +
    +
    diff --git a/libs/admin/src/lib/components/user-edit/user-edit.component.scss b/libs/admin/src/lib/components/user-edit/user-edit.component.scss new file mode 100644 index 00000000..b42f07b3 --- /dev/null +++ b/libs/admin/src/lib/components/user-edit/user-edit.component.scss @@ -0,0 +1,62 @@ +@use "../../styles/variables"; + +.card { + margin: 1rem; +} + +.user-edit { + padding: 1.5rem; + height: 100%; + overflow-y: scroll; +} + +.user-data { + display: flex; + gap: 0.5rem; + + .user-data__group { + display: flex; + gap: 1rem; + align-items: center; + } +} + +.workgroups { + & > .workgroups__header { + display: flex; + justify-content: space-between; + align-items: center; + + & > *:last-child > mat-form-field { + width: 20rem; + } + } + + .mat-column-name { + width: auto; + } + + .mat-column-role { + width: 12rem; + } + + .mat-column-actions { + width: 6rem; + } + + .workgroups__table__form-field { + padding: 0.5rem 0; + } + + .workgroups__table__header { + background-color: variables.$grey-03; + } + + .mat-mdc-header-row .mat-mdc-cell { + background-color: variables.$grey-01; + } + + .mat-mdc-row:hover .mat-mdc-cell { + background-color: variables.$grey-01; + } +} diff --git a/libs/admin/src/lib/components/user-edit/user-edit.component.ts b/libs/admin/src/lib/components/user-edit/user-edit.component.ts new file mode 100644 index 00000000..b22ae705 --- /dev/null +++ b/libs/admin/src/lib/components/user-edit/user-edit.component.ts @@ -0,0 +1,196 @@ +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { MatOptionSelectionChange } from '@angular/material/core'; +import { MatSelectChange } from '@angular/material/select'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { fromAppShared } from '@asset-sg/client-shared'; +import { isNotNull } from '@asset-sg/core'; +import { SimpleWorkgroup, User, WorkgroupId } from '@asset-sg/shared/v2'; +import * as RD from '@devexperts/remote-data-ts'; +import { Store } from '@ngrx/store'; +import { Role } from '@prisma/client'; +import { asapScheduler, filter, map, Observable, startWith, Subscription, withLatestFrom } from 'rxjs'; +import * as actions from '../../state/admin.actions'; +import { AppStateWithAdmin } from '../../state/admin.reducer'; +import { selectSelectedUser } from '../../state/admin.selector'; + +@Component({ + selector: 'asset-sg-user-edit', + templateUrl: './user-edit.component.html', + styleUrls: ['./user-edit.component.scss'], +}) +export class UserEditComponent implements OnInit, OnDestroy { + public roles = Object.values(Role); + public user: User | null = null; + public workgroups: SimpleWorkgroup[] = []; + public filteredWorkgroups: SimpleWorkgroup[] = []; + + public workgroupAutoCompleteControl = new FormControl(''); + public formGroup = new FormGroup({ + isAdmin: new FormControl(false), + lang: new FormControl('de'), + }); + + protected readonly COLUMNS = ['name', 'role', 'actions']; + + private readonly route = inject(ActivatedRoute); + private readonly store = inject(Store); + private readonly workgroups$ = this.store.select(fromAppShared.selectWorkgroups); + private readonly user$ = this.store.select(selectSelectedUser); + private readonly subscriptions: Subscription = new Subscription(); + + public readonly isCurrentUser$: Observable = this.store.select(fromAppShared.selectRDUserProfile).pipe( + map((currentUser) => (RD.isSuccess(currentUser) ? currentUser.value : null)), + filter(isNotNull), + withLatestFrom(this.user$.pipe(filter(isNotNull))), + map(([currentUser, user]) => currentUser.id === user.id), + startWith(true) + ); + + public readonly userWorkgroups$: Observable> = this.user$.pipe( + withLatestFrom(this.workgroups$), + map(([user, workgroups]) => { + if (user == null) { + return []; + } + const result: Array = []; + for (const workgroup of workgroups) { + const role = user.roles.get(workgroup.id); + if (role == null) { + continue; + } + result.push({ ...workgroup, role }); + } + return result; + }) + ); + + public ngOnInit() { + this.getUserFromRoute(); + this.initSubscriptions(); + } + + public ngOnDestroy() { + this.subscriptions.unsubscribe(); + } + + public resetWorkgroupSearch() { + this.workgroupAutoCompleteControl.setValue(''); + + // Redo the reset a tick later, as the input seems to fall back to its previous value. + asapScheduler.schedule(() => { + this.workgroupAutoCompleteControl.setValue(''); + }); + } + + public isUserPartOfWorkgroup(workgroupId: number): boolean { + return this.user != null && this.user.roles.has(workgroupId); + } + + public addWorkgroupRole(event: MatOptionSelectionChange, workgroupId: number) { + if (this.user == null || !event.isUserInput) { + return; + } + const roles = new Map(this.user.roles); + roles.set(workgroupId, Role.Viewer); + this.store.dispatch(actions.updateUser({ user: { ...this.user, roles } })); + this.resetWorkgroupSearch(); + } + + public updateWorkgroupRole(event: MatSelectChange, workgroupId: WorkgroupId) { + if (!this.user) { + return; + } + const roles = new Map(this.user.roles); + roles.set(workgroupId, event.value as Role); + this.updateUser({ ...this.user, roles }); + } + + public removeWorkgroupRole(workgroupId: WorkgroupId) { + if (!this.user) { + return; + } + const roles = new Map(this.user.roles); + roles.delete(workgroupId); + this.updateUser({ ...this.user, roles }); + } + + private updateUser(user: User) { + this.store.dispatch(actions.updateUser({ user })); + } + + private getUserFromRoute() { + this.subscriptions.add( + this.route.paramMap.subscribe((params: ParamMap) => { + const userId = params.get('id'); + if (userId) { + this.store.dispatch(actions.findUser({ userId })); + } + }) + ); + } + + private initializeForm() { + this.formGroup.patchValue( + { + isAdmin: this.user?.isAdmin ?? false, + lang: this.user?.lang ?? 'de', + }, + { emitEvent: false } + ); + } + + private initSubscriptions() { + this.subscriptions.add( + this.user$.subscribe((user) => { + this.user = user; + this.initializeForm(); + }) + ); + this.subscriptions.add( + this.workgroups$.subscribe((workgroups) => { + if (workgroups) { + this.workgroups = workgroups; + this.filteredWorkgroups = workgroups; + } + }) + ); + + this.subscriptions.add( + this.workgroupAutoCompleteControl.valueChanges + .pipe( + map((it) => it ?? ''), + startWith(''), + map((value) => + this.workgroups.filter((workgroup) => workgroup.name.toLowerCase().includes(value.toLowerCase().trim())) + ) + ) + .subscribe((workgroups) => { + this.filteredWorkgroups = workgroups; + }) + ); + + this.subscriptions.add( + this.formGroup.valueChanges.subscribe(() => { + if (this.user == null || this.formGroup.pristine) { + return; + } + this.updateUser({ + ...this.user, + isAdmin: this.formGroup.controls.isAdmin.value ?? false, + lang: this.formGroup.controls.lang.value ?? 'de', + }); + }) + ); + + this.subscriptions.add( + this.isCurrentUser$.subscribe((isCurrentUser) => { + if (isCurrentUser) { + this.formGroup.controls.isAdmin.disable(); + } else { + this.formGroup.controls.isAdmin.enable(); + } + }) + ); + } +} diff --git a/libs/admin/src/lib/components/user-expanded/index.ts b/libs/admin/src/lib/components/user-expanded/index.ts deleted file mode 100644 index 759678c0..00000000 --- a/libs/admin/src/lib/components/user-expanded/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './user-expanded.component'; diff --git a/libs/admin/src/lib/components/user-expanded/user-expanded.component.html b/libs/admin/src/lib/components/user-expanded/user-expanded.component.html deleted file mode 100644 index 7bbe4d03..00000000 --- a/libs/admin/src/lib/components/user-expanded/user-expanded.component.html +++ /dev/null @@ -1,46 +0,0 @@ -
    -
    -
    - - - -
    -
    userManagement.confirmDelete
    - -
    - - -
    -
    -
    -
    - - - Admin - Viewer - Editor - Master Editor - -
    Sprache
    - - Sprache - - DE - EN - FR - IT - RM - - -
    - - -
    -
    -
    diff --git a/libs/admin/src/lib/components/user-expanded/user-expanded.component.scss b/libs/admin/src/lib/components/user-expanded/user-expanded.component.scss deleted file mode 100644 index a53841a4..00000000 --- a/libs/admin/src/lib/components/user-expanded/user-expanded.component.scss +++ /dev/null @@ -1,72 +0,0 @@ -@use "../../styles/variables"; - -:host { - display: block; -} - -form { - display: flex; - flex-direction: column; -} - -.edit-area { - display: flex; - flex-direction: column; - background-color: variables.$white; - padding: 0rem 1rem 1rem 1rem; -} - -.label-email { - padding-top: 1rem; -} - -.header { - display: grid; - align-items: center; - padding: 0.5rem 0; - grid-template-areas: "email delete"; - grid-template-columns: 1fr auto; - margin-bottom: 1rem; -} - -.email { - grid-area: email; - color: variables.$cyan-09; - font-weight: variables.$font-bold; -} - -.email-form-field { - align-self: flex-start; - min-width: 32rem; -} - -.roles-radio-group { - margin-bottom: 1rem; - align-self: flex-start; -} - -label, -.label-heading { - font-size: 0.875rem; - color: variables.$grey-09; - font-weight: variables.$font-bold; -} - -.label-heading--before-form-field { - margin-bottom: 0.75rem; -} - -.lang-form-field { - align-self: flex-start; -} - -.mdc-text-field--filled:not(.mdc-text-field--disabled) { - background-color: variables.$grey-00; -} - -.asset-sg-dialog { - min-height: unset; - .email { - margin: 1rem 1rem 2rem 2rem; - } -} diff --git a/libs/admin/src/lib/components/user-expanded/user-expanded.component.ts b/libs/admin/src/lib/components/user-expanded/user-expanded.component.ts deleted file mode 100644 index be29377e..00000000 --- a/libs/admin/src/lib/components/user-expanded/user-expanded.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Dialog, DialogRef } from '@angular/cdk/dialog'; -import { - Component, - EventEmitter, - Input, - Output, - QueryList, - TemplateRef, - ViewChild, - ViewChildren, - inject, -} from '@angular/core'; -import { FormBuilder, FormControl } from '@angular/forms'; -import { ButtonComponent, ViewChildMarker } from '@asset-sg/client-shared'; -import { User, UserRoleEnum } from '@asset-sg/shared'; -import { makeADT, ofType } from '@morphic-ts/adt'; -import * as O from 'fp-ts/Option'; - -interface UserEdited extends User { - _tag: 'userEdited'; -} - -interface UserDelete extends User { - _tag: 'userDelete'; -} - -interface UserExpandCanceled { - _tag: 'userExpandCanceled'; -} - -export const UserExpandedOutput = makeADT('_tag')({ - userEdited: ofType(), - userExpandCanceled: ofType(), - userDelete: ofType(), -}); - -export type UserExpandedOutput = UserEdited | UserDelete | UserExpandCanceled; - -@Component({ - selector: 'asset-sg-user-expanded', - templateUrl: './user-expanded.component.html', - styleUrls: ['./user-expanded.component.scss'], -}) -export class UserExpandedComponent { - @ViewChild('deleteDialog') deleteDialog!: TemplateRef; - @ViewChildren(ViewChildMarker) viewChildMarkers!: QueryList; - - @Output() - public userExpandedOutput = new EventEmitter(); - private _dialog = inject(Dialog); - - private _formBulder = inject(FormBuilder); - public editForm = this._formBulder.group({ - email: new FormControl(''), - role: new FormControl(UserRoleEnum.viewer), - lang: new FormControl('de'), - }); - public UserRole = UserRoleEnum; - - private _dialogRef: O.Option = O.none; - - @Input() - public get user(): User | undefined { - return this._user; - } - public set user(value: User | undefined) { - this._user = value; - if (value) { - this.editForm.patchValue({ - email: value.email, - role: value.role, - lang: value.lang, - }); - } - } - private _user?: User | undefined; - - disableEverything() { - this.editForm.disable(); - this.viewChildMarkers.forEach((marker) => { - if (marker.viewChildMarker instanceof ButtonComponent) { - marker.viewChildMarker.disabled = true; - } - }); - } - submit() { - this.disableEverything(); - if (this.user) { - const { role, lang } = this.editForm.getRawValue(); - - if (!role || !lang) return; - - this.userExpandedOutput.emit(UserExpandedOutput.of.userEdited({ ...this.user, role, lang })); - } - } - - cancel() { - this.userExpandedOutput.emit(UserExpandedOutput.of.userExpandCanceled({})); - } - - delete() { - this._dialogRef = O.some(this._dialog.open(this.deleteDialog)); - } - - deleteCancelled() { - if (O.isSome(this._dialogRef)) { - this._dialogRef.value.close(); - } - } - - deleteConfirmed() { - this.disableEverything(); - if (O.isSome(this._dialogRef)) { - this._dialogRef.value.close(); - } - if (this.user) { - this.user && this.userExpandedOutput.emit(UserExpandedOutput.of.userDelete(this.user)); - } - } -} diff --git a/libs/admin/src/lib/components/users/users.component.html b/libs/admin/src/lib/components/users/users.component.html new file mode 100644 index 00000000..0d7f4629 --- /dev/null +++ b/libs/admin/src/lib/components/users/users.component.html @@ -0,0 +1,55 @@ +
    +
    +

    admin.users

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    admin.email{{ user.email }}admin.userPage.admin + + admin.userPage.lang{{ user.lang }}admin.workgroups + @for (workgroup of getUserWorkgroups(user); track workgroup.id; let isLast = $last) { + {{ workgroup.name }}.{{ workgroup.role }} + , + } @if (user.roles.size > WORKGROUP_DISPLAY_COUNT) { + , +{{ user.roles.size - WORKGROUP_DISPLAY_COUNT }} admin.userPage.more + } + admin.actions + +
    +
    +
    diff --git a/libs/admin/src/lib/components/users/users.component.scss b/libs/admin/src/lib/components/users/users.component.scss new file mode 100644 index 00000000..4c9c9fc8 --- /dev/null +++ b/libs/admin/src/lib/components/users/users.component.scss @@ -0,0 +1,27 @@ +@use "../../styles/variables"; + +::ng-deep .workgroups-tooltip { + font-size: 12px; +} + +.users { + height: 100%; + display: flex; + flex-direction: column; + padding: 0 1rem; +} + +.header { + display: flex; + justify-content: flex-start; + align-items: center; + margin-bottom: 1rem; +} + +.table__header { + background-color: variables.$grey-03; +} + +.mat-mdc-row:hover .mat-mdc-cell { + background-color: variables.$grey-01; +} diff --git a/libs/admin/src/lib/components/users/users.component.ts b/libs/admin/src/lib/components/users/users.component.ts new file mode 100644 index 00000000..9bdbff13 --- /dev/null +++ b/libs/admin/src/lib/components/users/users.component.ts @@ -0,0 +1,87 @@ +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { MatCheckboxChange } from '@angular/material/checkbox'; +import { fromAppShared } from '@asset-sg/client-shared'; +import { isNotNull } from '@asset-sg/core'; +import { Role, User, UserOnWorkgroup, Workgroup, WorkgroupId } from '@asset-sg/shared/v2'; +import * as RD from '@devexperts/remote-data-ts'; +import { Store } from '@ngrx/store'; +import { filter, map, Observable, startWith, Subscription, withLatestFrom } from 'rxjs'; +import * as actions from '../../state/admin.actions'; +import { AppStateWithAdmin } from '../../state/admin.reducer'; +import { selectUsers, selectWorkgroups } from '../../state/admin.selector'; + +@Component({ + selector: 'asset-sg-users', + templateUrl: './users.component.html', + styleUrls: ['./users.component.scss'], +}) +export class UsersComponent implements OnInit, OnDestroy { + public workgroups = new Map(); + + protected readonly COLUMNS = ['email', 'isAdmin', 'languages', 'workgroups', 'actions']; + protected readonly WORKGROUP_DISPLAY_COUNT = 3; + + private readonly store = inject(Store); + public readonly users$ = this.store.select(selectUsers); + private readonly workgroups$ = this.store.select(selectWorkgroups); + private readonly subscriptions: Subscription = new Subscription(); + + public readonly currentUser$: Observable = this.store.select(fromAppShared.selectRDUserProfile).pipe( + map((currentUser) => (RD.isSuccess(currentUser) ? currentUser.value : null)), + filter(isNotNull) + ); + + public ngOnInit(): void { + this.initSubscriptions(); + } + + public ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + public *getUserWorkgroups(user: User): Iterable { + const iter = user.roles.entries(); + for (let i = 0; i < this.WORKGROUP_DISPLAY_COUNT; i++) { + const { value, done } = iter.next(); + if (done) { + break; + } + const [workgroupId, role] = value; + const workgroup = this.workgroups.get(workgroupId); + if (workgroup == null) { + continue; + } + yield { ...workgroup, role }; + } + } + + public updateIsAdminStatus(user: User, event: MatCheckboxChange) { + this.store.dispatch(actions.updateUser({ user: { ...user, isAdmin: event.checked } })); + } + + public formatWorkgroupsTooltip(roles: User['roles']): string { + let tooltip = ''; + for (const [workgroupId, workgroupRole] of roles) { + const workgroup = this.workgroups.get(workgroupId); + if (workgroup == null) { + continue; + } + if (tooltip.length !== 0) { + tooltip += ',\n'; + } + tooltip += `${workgroup.name}.${workgroupRole}`; + } + return tooltip; + } + + private initSubscriptions(): void { + this.subscriptions.add( + this.workgroups$.subscribe((workgroups) => { + this.workgroups.clear(); + for (const workgroup of workgroups) { + this.workgroups.set(workgroup.id, workgroup); + } + }) + ); + } +} diff --git a/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.html b/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.html new file mode 100644 index 00000000..0c7fc666 --- /dev/null +++ b/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.html @@ -0,0 +1,66 @@ + +
    + +
    +
    + + admin.name + + + admin.workgroupPage.isDisabled + +
    +
    + +
    +
    +

    admin.users

    + +
    + + @if (workgroup.users.size === 0) { +

    admin.workgroupPage.chooseUsersText

    + } @else { + + + + + + + + + + + + + + + + + + +
    admin.email{{ user.email }}admin.role + + admin.role + + + {{ role }} + + + + admin.actions + +
    + } +
    + + +
    +
    +
    +
    diff --git a/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.scss b/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.scss new file mode 100644 index 00000000..15d36ac5 --- /dev/null +++ b/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.scss @@ -0,0 +1,59 @@ +@use "../../styles/variables"; + +.card { + margin: 1rem; +} + +.workgroup-edit { + padding: 1.5rem; +} + +.form { + display: flex; + align-items: center; + gap: 1rem; +} + +.users-table { + .users-table__header { + background-color: variables.$grey-03; + } + + .mat-column-id { + width: auto !important; + } + + .mat-column-role { + width: 10% !important; + } + + .mat-column-actions { + width: 5% !important; + } + + .mat-mdc-header-row .mat-mdc-cell { + background-color: variables.$grey-01; + } + + .mat-mdc-row:hover .mat-mdc-cell { + background-color: variables.$grey-01; + } + + .users-table__title { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + + .users-table__form-field { + padding: 0.5rem 0; + } + + .users-table__save { + display: flex; + justify-content: flex-end; + align-items: center; + margin-top: 1rem; + } +} diff --git a/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.ts b/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.ts new file mode 100644 index 00000000..288344dc --- /dev/null +++ b/libs/admin/src/lib/components/workgroup-edit/workgroup-edit.component.ts @@ -0,0 +1,185 @@ +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSelectChange } from '@angular/material/select'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { UserId, UserOnWorkgroup, Workgroup, WorkgroupData } from '@asset-sg/shared/v2'; +import { Store } from '@ngrx/store'; +import { Role } from '@prisma/client'; +import { BehaviorSubject, map, share, startWith, Subscription } from 'rxjs'; +import * as actions from '../../state/admin.actions'; +import { AppStateWithAdmin } from '../../state/admin.reducer'; +import { selectSelectedWorkgroup } from '../../state/admin.selector'; +import { AddWorkgroupUserDialogComponent } from '../add-workgroup-user-dialog/add-workgroup-user-dialog.component'; + +export type Mode = 'edit' | 'create'; + +@Component({ + selector: 'asset-sg-workgroup-edit', + templateUrl: './workgroup-edit.component.html', + styleUrls: ['./workgroup-edit.component.scss'], +}) +export class WorkgroupEditComponent implements OnInit, OnDestroy { + public workgroup$ = new BehaviorSubject(null); + public mode: Mode = 'edit'; + + public readonly roles: Role[] = Object.values(Role); + public readonly formGroup = new FormGroup({ + name: new FormControl('', { validators: Validators.required, updateOn: 'blur', nonNullable: true }), + disabledAt: new FormControl(null), + users: new FormControl>(new Map(), { nonNullable: true }), + }); + + private readonly route = inject(ActivatedRoute); + private readonly dialogService = inject(MatDialog); + private readonly subscriptions: Subscription = new Subscription(); + private readonly store = inject(Store); + private readonly router = inject(Router); + private readonly selectedWorkgroup$ = this.store.select(selectSelectedWorkgroup); + + public readonly users$ = this.workgroup$.pipe( + map((workgroup) => { + if (workgroup == null) { + return []; + } + const users: Array = []; + for (const [id, user] of workgroup.users) { + users.push({ ...user, id }); + } + return users; + }), + startWith([]), + share() + ); + + public ngOnInit() { + this.loadWorkgroupFromRouteParams(); + this.initializeSubscriptions(); + } + + public ngOnDestroy() { + this.store.dispatch(actions.resetWorkgroup()); + this.subscriptions.unsubscribe(); + } + + private get workgroup(): Workgroup | null { + return this.workgroup$.value; + } + + public initializeForm(workgroup: Workgroup) { + this.formGroup.patchValue( + { + name: workgroup.name, + disabledAt: workgroup.disabledAt, + users: workgroup.users, + }, + { emitEvent: false } + ); + } + + private initializeSubscriptions() { + this.subscriptions.add( + this.selectedWorkgroup$.subscribe((workgroup) => { + if (workgroup) { + this.workgroup$.next(workgroup); + this.initializeForm(workgroup); + } + }) + ); + + this.subscriptions.add( + this.formGroup.valueChanges.subscribe(() => { + if (this.workgroup == null || this.formGroup.pristine) { + return; + } + this.updateWorkgroup(this.workgroup.id, { + name: this.formGroup.controls.name.value ?? '', + disabledAt: this.formGroup.controls.disabledAt.value ?? null, + users: this.workgroup.users, + }); + }) + ); + } + + public updateRoleForUser(event: MatSelectChange, userId: UserId, user: UserOnWorkgroup) { + if (this.workgroup == null) { + return; + } + const users = new Map(this.workgroup.users); + users.set(userId, { + email: user.email, + role: event.value as Role, + }); + this.updateWorkgroup(this.workgroup.id, { + ...this.workgroup, + users, + }); + } + + public addUsersToWorkgroup() { + this.dialogService.open(AddWorkgroupUserDialogComponent, { + width: '400px', + restoreFocus: false, + data: { + workgroup: this.workgroup, + mode: this.mode, + }, + }); + } + + public deleteUserFromWorkgroup(userId: UserId) { + if (this.workgroup == null) { + return; + } + const users = new Map(this.workgroup.users); + users.delete(userId); + this.updateWorkgroup(this.workgroup.id, { + ...this.workgroup, + users, + }); + } + + public cancel() { + void this.router.navigate(['../'], { relativeTo: this.route }); + this.store.dispatch(actions.resetWorkgroup()); + } + + public createWorkgroup() { + if (this.workgroup == null || !this.formGroup.valid) { + return; + } + const { id: _id, ...workgroup } = this.workgroup; + this.store.dispatch(actions.createWorkgroup({ workgroup })); + } + + private updateWorkgroup(workgroupId: number, workgroup: WorkgroupData) { + if (this.mode === 'edit') { + this.store.dispatch(actions.updateWorkgroup({ workgroupId, workgroup })); + } else { + this.workgroup$.next({ + id: workgroupId, + ...workgroup, + }); + } + } + + private loadWorkgroupFromRouteParams() { + this.subscriptions.add( + this.route.paramMap.subscribe((params: ParamMap) => { + const id = params.get('id'); + if (id) { + this.mode = 'edit'; + return this.store.dispatch(actions.findWorkgroup({ workgroupId: parseInt(id) })); + } else { + this.mode = 'create'; + this.workgroup$.next({ + id: 0, + name: '', + users: new Map(), + disabledAt: null, + }); + } + }) + ); + } +} diff --git a/libs/admin/src/lib/components/workgroups/workgroups.component.html b/libs/admin/src/lib/components/workgroups/workgroups.component.html new file mode 100644 index 00000000..45761627 --- /dev/null +++ b/libs/admin/src/lib/components/workgroups/workgroups.component.html @@ -0,0 +1,36 @@ +
    +
    +

    admin.workgroups

    + +
    +
    + + + + + + + + + + + + + + + + + + + +
    admin.name{{ workgroup.name }}admin.users + @for (user of getWorkgroupUsers(workgroup); track user.id) { + {{ user.email }}, + } + admin.workgroupPage.isActive{{ workgroup.disabled_at ? "inaktiv" : "aktiv" }}admin.actions + +
    +
    +
    diff --git a/libs/admin/src/lib/components/workgroups/workgroups.component.scss b/libs/admin/src/lib/components/workgroups/workgroups.component.scss new file mode 100644 index 00000000..ca38ae9a --- /dev/null +++ b/libs/admin/src/lib/components/workgroups/workgroups.component.scss @@ -0,0 +1,27 @@ +@use "../../styles/variables"; + +.workgroups { + height: 100%; + display: flex; + flex-direction: column; + padding: 0 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.table__header { + background-color: variables.$grey-03; +} + +.mat-mdc-header-row .mat-mdc-cell { + background-color: variables.$grey-01; +} + +.mat-mdc-row:hover .mat-mdc-cell { + background-color: variables.$grey-01; +} diff --git a/libs/admin/src/lib/components/workgroups/workgroups.component.ts b/libs/admin/src/lib/components/workgroups/workgroups.component.ts new file mode 100644 index 00000000..2ac59c00 --- /dev/null +++ b/libs/admin/src/lib/components/workgroups/workgroups.component.ts @@ -0,0 +1,26 @@ +import { KeyValue } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { UserId, UserOnWorkgroup, Workgroup } from '@asset-sg/shared/v2'; +import { Store } from '@ngrx/store'; +import { AppStateWithAdmin } from '../../state/admin.reducer'; +import { selectWorkgroups } from '../../state/admin.selector'; + +@Component({ + selector: 'asset-sg-workgroups', + templateUrl: './workgroups.component.html', + styleUrls: ['./workgroups.component.scss'], +}) +export class WorkgroupsComponent { + protected readonly COLUMNS = ['name', 'users', 'status', 'actions']; + + private readonly store = inject(Store); + readonly workgroups$ = this.store.select(selectWorkgroups); + + getWorkgroupUsers(workgroup: Workgroup): Array { + const result: Array = []; + for (const [id, user] of workgroup.users) { + result.push({ ...user, id }); + } + return result; + } +} diff --git a/libs/admin/src/lib/services/admin.service.ts b/libs/admin/src/lib/services/admin.service.ts index 92d6d173..c5c3838a 100644 --- a/libs/admin/src/lib/services/admin.service.ts +++ b/libs/admin/src/lib/services/admin.service.ts @@ -1,12 +1,18 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { ApiError, httpErrorResponseError } from '@asset-sg/client-shared'; -import { OE, ORD, decodeError } from '@asset-sg/core'; -import { User, Users } from '@asset-sg/shared'; -import * as RD from '@devexperts/remote-data-ts'; -import * as E from 'fp-ts/Either'; -import { flow } from 'fp-ts/function'; -import { map, startWith, tap } from 'rxjs'; +import { inject, Injectable } from '@angular/core'; +import { + convert, + User, + UserData, + UserDataSchema, + UserSchema, + Workgroup, + WorkgroupData, + WorkgroupDataSchema, + WorkgroupSchema, +} from '@asset-sg/shared/v2'; +import { plainToInstance } from 'class-transformer'; +import { map, Observable, tap } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -14,41 +20,51 @@ import { map, startWith, tap } from 'rxjs'; export class AdminService { private _httpClient = inject(HttpClient); - public getUsers(): ORD.ObservableRemoteData { - return this._httpClient.get('/api/admin/user').pipe( - map(flow(Users.decode, E.mapLeft(decodeError))), - // TODO need to test instance of HttpErrorResponse here - OE.catchErrorW(httpErrorResponseError), - map(RD.fromEither), - startWith(RD.pending) - ); + public getUsers(): Observable { + return this._httpClient.get('/api/users').pipe(map((it) => plainToInstance(UserSchema, it))); } - public updateUser(user: User): ORD.ObservableRemoteData { + public getUser(id: string): Observable { + return this._httpClient.get(`/api/users/${id}`).pipe(map((it) => plainToInstance(UserSchema, it))); + } + + public getWorkgroups(): Observable { + return this._httpClient.get('/api/workgroups').pipe(map((it) => plainToInstance(WorkgroupSchema, it))); + } + + public getWorkgroup(id: string): Observable { return this._httpClient - .patch(`/api/admin/user/${user.id}`, { - role: user.role, - lang: user.lang, - }) - .pipe( - map(() => E.right(undefined)), - // TODO need to test instance of HttpErrorResponse here - OE.catchErrorW(httpErrorResponseError), - map(RD.fromEither), - startWith(RD.pending) - ); + .get(`/api/workgroups/${id}`) + .pipe(map((it) => plainToInstance(WorkgroupSchema, it))); } - public deleteUser(id: string): ORD.ObservableRemoteData { - console.log('888', id); - return this._httpClient.delete(`/api/admin/user/${id}`).pipe( - tap((a) => console.log('deleteUser', a)), - map(() => E.right(undefined)), - // TODO need to test instance of HttpErrorResponse here - OE.catchErrorW(httpErrorResponseError), - map(RD.fromEither), - startWith(RD.pending), - tap((a) => console.log('deleteUser', a)) - ); + public createWorkgroup(workgroup: WorkgroupData): Observable { + return this._httpClient.post(`/api/workgroups`, convertWorkgroupData(workgroup)); + } + + public updateWorkgroup(id: number, workgroup: WorkgroupData): Observable { + return this._httpClient + .put(`/api/workgroups/${id}`, convertWorkgroupData(workgroup)) + .pipe(map((it) => plainToInstance(WorkgroupSchema, it))); + } + + public updateUser(user: User): Observable { + return this._httpClient + .put( + `/api/users/${user.id}`, + convert(UserDataSchema, { + lang: user.lang, + roles: user.roles, + isAdmin: user.isAdmin, + } as UserData) + ) + .pipe(map((it) => plainToInstance(UserSchema, it))); } } + +const convertWorkgroupData = (data: WorkgroupData): WorkgroupData => + convert(WorkgroupDataSchema, { + name: data.name, + users: data.users, + disabledAt: data.disabledAt, + } as WorkgroupData); diff --git a/libs/admin/src/lib/state/admin.actions.ts b/libs/admin/src/lib/state/admin.actions.ts new file mode 100644 index 00000000..69295154 --- /dev/null +++ b/libs/admin/src/lib/state/admin.actions.ts @@ -0,0 +1,74 @@ +import { User, Workgroup, WorkgroupData } from '@asset-sg/shared/v2'; +import { createAction, props } from '@ngrx/store'; + +export const findUser = createAction( + '[Admin] Find User', + props<{ + userId: string; + }>() +); + +export const setUser = createAction('[Admin] Set User', props<{ user: User }>()); + +export const updateUser = createAction( + '[Admin] Update User', + props<{ + user: User; + }>() +); + +export const listUsers = createAction('[Admin] List Users'); + +export const setUsers = createAction( + '[Admin] Set Users', + props<{ + users: User[]; + }>() +); + +export const findWorkgroup = createAction( + '[Admin] Find Workgroup', + props<{ + workgroupId: number; + }>() +); + +export const setWorkgroup = createAction( + '[Admin] Set Workgroup', + props<{ + workgroup: Workgroup; + }>() +); + +export const addWorkgroup = createAction( + '[Admin] Add Workgroup', + props<{ + workgroup: Workgroup; + }>() +); + +export const resetWorkgroup = createAction('[Admin] Reset Workgroup'); + +export const setWorkgroups = createAction( + '[Admin] Set Workgroups', + props<{ + workgroups: Workgroup[]; + }>() +); + +export const updateWorkgroup = createAction( + '[Admin] Update Workgroup', + props<{ + workgroupId: number; + workgroup: WorkgroupData; + }>() +); + +export const createWorkgroup = createAction( + '[Admin] Create Workgroup', + props<{ + workgroup: WorkgroupData; + }>() +); + +export const listWorkgroups = createAction('[Admin] List Workgroups'); diff --git a/libs/admin/src/lib/state/admin.effects.ts b/libs/admin/src/lib/state/admin.effects.ts new file mode 100644 index 00000000..10d31fb3 --- /dev/null +++ b/libs/admin/src/lib/state/admin.effects.ts @@ -0,0 +1,86 @@ +import { inject, Injectable } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CURRENT_LANG } from '@asset-sg/client-shared'; +import { User, Workgroup } from '@asset-sg/shared/v2'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { first, map, switchMap, tap, withLatestFrom } from 'rxjs'; + +import { AdminService } from '../services/admin.service'; +import * as actions from './admin.actions'; + +@UntilDestroy() +@Injectable() +export class AdminEffects { + private readonly actions$ = inject(Actions); + private readonly adminService = inject(AdminService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly currentLang$ = inject(CURRENT_LANG); + + public findUser$ = createEffect(() => + this.actions$.pipe( + ofType(actions.findUser), + switchMap(({ userId }) => this.adminService.getUser(userId).pipe(map((user) => actions.setUser({ user })))) + ) + ); + + public updateUser$ = createEffect(() => + this.actions$.pipe( + ofType(actions.updateUser), + switchMap(({ user }) => this.adminService.updateUser(user).pipe(map((user: User) => actions.setUser({ user })))) + ) + ); + + public findWorkgroup$ = createEffect(() => + this.actions$.pipe( + ofType(actions.findWorkgroup), + switchMap(({ workgroupId }) => + this.adminService + .getWorkgroup(workgroupId.toString()) + .pipe(map((workgroup: Workgroup) => actions.setWorkgroup({ workgroup }))) + ) + ) + ); + + public createWorkgroup$ = createEffect(() => + this.actions$.pipe( + ofType(actions.createWorkgroup), + switchMap(({ workgroup }) => this.adminService.createWorkgroup(workgroup)), + withLatestFrom(this.currentLang$), + map(([workgroup, currentLang]) => { + void this.router.navigate([`/${currentLang}/admin/workgroups/${workgroup.id}`], { relativeTo: this.route }); + return actions.addWorkgroup({ workgroup }); + }) + ) + ); + + public updateWorkgroup$ = createEffect(() => + this.actions$.pipe( + ofType(actions.updateWorkgroup), + switchMap(({ workgroupId, workgroup }) => + this.adminService + .updateWorkgroup(workgroupId, workgroup) + .pipe(map((workgroup: Workgroup) => actions.setWorkgroup({ workgroup }))) + ) + ) + ); + + public listUsers$ = createEffect(() => + this.actions$.pipe( + ofType(actions.listUsers, actions.setUser, actions.setWorkgroup), + first(), + switchMap(() => this.adminService.getUsers().pipe(map((users: User[]) => actions.setUsers({ users })))) + ) + ); + + public listWorkgroups$ = createEffect(() => + this.actions$.pipe( + ofType(actions.listWorkgroups, actions.setWorkgroup, actions.setUser), + first(), + switchMap(() => + this.adminService.getWorkgroups().pipe(map((workgroups: Workgroup[]) => actions.setWorkgroups({ workgroups }))) + ) + ) + ); +} diff --git a/libs/admin/src/lib/state/admin.reducer.ts b/libs/admin/src/lib/state/admin.reducer.ts new file mode 100644 index 00000000..a5f45d6f --- /dev/null +++ b/libs/admin/src/lib/state/admin.reducer.ts @@ -0,0 +1,130 @@ +import { AppState } from '@asset-sg/client-shared'; +import { User, Workgroup } from '@asset-sg/shared/v2'; +import { createReducer, on } from '@ngrx/store'; +import * as actions from './admin.actions'; + +export interface AdminState { + selectedWorkgroup: Workgroup | null; + workgroups: Workgroup[]; + selectedUser: User | null; + users: User[]; + isLoading: boolean; +} + +export interface AppStateWithAdmin extends AppState { + admin: AdminState; +} + +const initialState: AdminState = { + selectedWorkgroup: null, + workgroups: [], + selectedUser: null, + users: [], + isLoading: false, +}; + +export const adminReducer = createReducer( + initialState, + on( + actions.findUser, + (state): AdminState => ({ + ...state, + isLoading: true, + }) + ), + on( + actions.updateUser, + (state): AdminState => ({ + ...state, + isLoading: true, + }) + ), + on( + actions.setUser, + (state, { user }): AdminState => ({ + ...state, + selectedUser: user, + isLoading: false, + }) + ), + on( + actions.findWorkgroup, + (state): AdminState => ({ + ...state, + isLoading: true, + }) + ), + on( + actions.updateWorkgroup, + (state): AdminState => ({ + ...state, + isLoading: true, + }) + ), + on( + actions.createWorkgroup, + (state): AdminState => ({ + ...state, + isLoading: true, + }) + ), + on(actions.setWorkgroup, (state, { workgroup }): AdminState => { + const workgroups = [...state.workgroups]; + const i = workgroups.findIndex((it) => it.id === workgroup.id); + if (i < 0) { + workgroups.push(workgroup); + } else { + workgroups[i] = workgroup; + } + return { + ...state, + workgroups, + selectedWorkgroup: workgroup, + isLoading: false, + }; + }), + on( + actions.addWorkgroup, + (state, { workgroup }): AdminState => ({ + ...state, + workgroups: [...state.workgroups, workgroup], + }) + ), + on( + actions.resetWorkgroup, + (state): AdminState => ({ + ...state, + selectedWorkgroup: null, + }) + ), + on( + actions.listUsers, + (state): AdminState => ({ + ...state, + isLoading: true, + }) + ), + on( + actions.setUsers, + (state, { users }): AdminState => ({ + ...state, + users, + isLoading: false, + }) + ), + on( + actions.listWorkgroups, + (state): AdminState => ({ + ...state, + isLoading: true, + }) + ), + on( + actions.setWorkgroups, + (state, { workgroups }): AdminState => ({ + ...state, + workgroups, + isLoading: false, + }) + ) +); diff --git a/libs/admin/src/lib/state/admin.selector.ts b/libs/admin/src/lib/state/admin.selector.ts new file mode 100644 index 00000000..94b5131f --- /dev/null +++ b/libs/admin/src/lib/state/admin.selector.ts @@ -0,0 +1,10 @@ +import { createSelector } from '@ngrx/store'; +import { AppStateWithAdmin } from './admin.reducer'; + +const adminFeature = (state: AppStateWithAdmin) => state.admin; + +export const selectSelectedUser = createSelector(adminFeature, (state) => state.selectedUser); +export const selectSelectedWorkgroup = createSelector(adminFeature, (state) => state.selectedWorkgroup); +export const selectWorkgroups = createSelector(adminFeature, (state) => state.workgroups); +export const selectUsers = createSelector(adminFeature, (state) => state.users); +export const selectIsLoading = createSelector(adminFeature, (state) => state.isLoading); diff --git a/libs/asset-editor/src/lib/asset-editor.module.ts b/libs/asset-editor/src/lib/asset-editor.module.ts index 6657b89c..6b0c3043 100644 --- a/libs/asset-editor/src/lib/asset-editor.module.ts +++ b/libs/asset-editor/src/lib/asset-editor.module.ts @@ -1,7 +1,7 @@ import { A11yModule } from '@angular/cdk/a11y'; import { DialogModule } from '@angular/cdk/dialog'; import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; +import { inject, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -12,7 +12,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; -import { CanDeactivateFn, RouterModule } from '@angular/router'; +import { CanActivateFn, CanDeactivateFn, RouterModule } from '@angular/router'; import { AnchorComponent, ButtonComponent, @@ -21,23 +21,30 @@ import { DatepickerToggleIconComponent, DrawerComponent, DrawerPanelComponent, - IsNotMasterEditorPipe, MatDateIdModule, ValueItemDescriptionPipe, ValueItemNamePipe, ViewChildMarker, + fromAppShared, + AdminOnlyDirective, } from '@asset-sg/client-shared'; +import { isNotNull, ORD } from '@asset-sg/core'; +import { AssetEditPolicy } from '@asset-sg/shared/v2'; +import * as RD from '@devexperts/remote-data-ts'; import { SvgIconComponent } from '@ngneat/svg-icon'; import { EffectsModule } from '@ngrx/effects'; -import { StoreModule } from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { ForModule } from '@rx-angular/template/for'; import { LetModule } from '@rx-angular/template/let'; import { PushModule } from '@rx-angular/template/push'; import { de } from 'date-fns/locale/de'; +import * as O from 'fp-ts/Option'; +import { combineLatest, filter, map, tap, withLatestFrom } from 'rxjs'; import { AssetEditorLaunchComponent } from './components/asset-editor-launch'; import { AssetEditorPageComponent } from './components/asset-editor-page'; +import { AssetEditorSyncComponent } from './components/asset-editor-sync/asset-editor-sync.component'; import { AssetEditorTabAdministrationComponent, ReplaceBrPipe } from './components/asset-editor-tab-administration'; import { AssetEditorTabContactsComponent } from './components/asset-editor-tab-contacts'; import { AssetEditorTabGeneralComponent } from './components/asset-editor-tab-general'; @@ -49,12 +56,14 @@ import { AssetMultiselectComponent } from './components/asset-multiselect'; import { Lv95xWithoutPrefixPipe, Lv95yWithoutPrefixPipe } from './components/lv95-without-prefix'; import { AssetEditorEffects } from './state/asset-editor.effects'; import { assetEditorReducer } from './state/asset-editor.reducer'; +import * as fromAssetEditor from './state/asset-editor.selectors'; export const canLeaveEdit: CanDeactivateFn = (c) => c.canLeave(); @NgModule({ declarations: [ AssetEditorLaunchComponent, + AssetEditorSyncComponent, AssetEditorPageComponent, AssetEditorTabAdministrationComponent, AssetEditorTabContactsComponent, @@ -74,6 +83,20 @@ export const canLeaveEdit: CanDeactivateFn = (c) => c. path: '', pathMatch: 'full', component: AssetEditorLaunchComponent, + + // Only users that can create assets are permitted to see the asset admin page. + canActivate: [ + (() => { + const store = inject(Store); + return store.select(fromAppShared.selectUser).pipe( + filter(isNotNull), + map((user) => { + const policy = new AssetEditPolicy(user); + return policy.canDoEverything() || policy.canCreate(); + }) + ); + }) as CanActivateFn, + ], }, { path: ':assetId', @@ -89,6 +112,28 @@ export const canLeaveEdit: CanDeactivateFn = (c) => c. canDeactivate: [canLeaveEdit], }, ], + + // Only users that can create new assets are permitted to access the new asset page. + // Only users that can edit the selected asset are permitted to access the edit asset page. + canActivate: [ + (() => { + const store = inject(Store); + return combineLatest([ + store + .select(fromAssetEditor.selectRDAssetEditDetail) + .pipe(map(RD.toNullable), filter(isNotNull), map(O.toNullable)), + store.select(fromAppShared.selectUser).pipe(filter(isNotNull)), + ]).pipe( + map(([assetEditDetail, user]) => { + const policy = new AssetEditPolicy(user); + return ( + policy.canDoEverything() || + (assetEditDetail == null ? policy.canCreate() : policy.canUpdate(assetEditDetail)) + ); + }) + ); + }) as CanActivateFn, + ], }, ]), TranslateModule.forChild(), @@ -117,7 +162,6 @@ export const canLeaveEdit: CanDeactivateFn = (c) => c. DrawerComponent, DrawerPanelComponent, DatepickerToggleIconComponent, - IsNotMasterEditorPipe, MatAutocompleteModule, MatCheckboxModule, @@ -128,6 +172,7 @@ export const canLeaveEdit: CanDeactivateFn = (c) => c. MatMenuModule, MatProgressBarModule, MatSelectModule, + AdminOnlyDirective, ], providers: [ { provide: MAT_DATE_LOCALE, useValue: de }, diff --git a/libs/asset-editor/src/lib/components/asset-editor-form-group.ts b/libs/asset-editor/src/lib/components/asset-editor-form-group.ts index a4313564..8e1b6122 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-form-group.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-form-group.ts @@ -34,6 +34,9 @@ const makeAssetEditorGeneralFormGroup = (formBuilder: FormBuilder) => assetFiles: new FormControl<(AssetFile & { willBeDeleted: boolean })[]>([], { nonNullable: true }), filesToDelete: new FormControl([], { nonNullable: true }), newFiles: formBuilder.array>([]), + workgroupId: new FormControl(null, { + validators: Validators.required, + }), }); export type AssetEditorGeneralFormGroup = ReturnType; diff --git a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.html b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.html index e95e0d7b..6f890eb3 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.html +++ b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.html @@ -7,14 +7,8 @@

    edit.adminInstructionsEditHeading

    edit.adminInstructionsEdit

    edit.adminInstructionsCreateHeading

    edit.adminInstructionsCreate -

    edit.adminInstructionsSyncElasticAssetsHeading

    -
    - -
    {{ syncProgress }}%
    -
    -

    edit.adminInstructionsSyncElasticAssets

    + + diff --git a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts index 58304884..1bab23e9 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts @@ -1,24 +1,22 @@ import { TemplatePortal } from '@angular/cdk/portal'; -import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - OnInit, + inject, TemplateRef, ViewChild, ViewContainerRef, - inject, } from '@angular/core'; import { AppPortalService, + appSharedStateActions, AppState, LifecycleHooks, LifecycleHooksDirective, - appSharedStateActions, } from '@asset-sg/client-shared'; import { Store } from '@ngrx/store'; -import { Observable, asyncScheduler, observeOn, take } from 'rxjs'; +import { asyncScheduler, observeOn } from 'rxjs'; @Component({ selector: 'asset-sg-editor-launch', @@ -27,7 +25,7 @@ import { Observable, asyncScheduler, observeOn, take } from 'rxjs'; hostDirectives: [LifecycleHooksDirective], styleUrls: ['./asset-editor-launch.component.scss'], }) -export class AssetEditorLaunchComponent implements OnInit { +export class AssetEditorLaunchComponent { @ViewChild('templateDrawerPortalContent') _templateDrawerPortalContent!: TemplateRef; private _lc = inject(LifecycleHooks); @@ -35,9 +33,6 @@ export class AssetEditorLaunchComponent implements OnInit { private _viewContainerRef = inject(ViewContainerRef); private _cd = inject(ChangeDetectorRef); private _store = inject>(Store); - private _httpClient = inject(HttpClient); - - syncProgress: number | null = null; constructor() { this._lc.afterViewInit$.pipe(observeOn(asyncScheduler)).subscribe(() => { @@ -49,42 +44,4 @@ export class AssetEditorLaunchComponent implements OnInit { this._store.dispatch(appSharedStateActions.openPanel()); }); } - - ngOnInit() { - void this.refreshAssetSyncProgress().then(() => { - if (this.syncProgress != null) { - void this.loopAssetSyncProgress(); - } - }); - } - - async synchronizeElastic() { - if (this.syncProgress !== null) { - return; - } - this.syncProgress = 0; - await resolveFirst(this._httpClient.post('/api/assets/sync', null)); - await this.loopAssetSyncProgress(); - } - - private async loopAssetSyncProgress() { - while (this.syncProgress !== null) { - await this.refreshAssetSyncProgress(); - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - - private async refreshAssetSyncProgress() { - type Progress = { progress: number } | null | undefined; - const progress = await resolveFirst(this._httpClient.get('/api/assets/sync')); - this.syncProgress = progress == null ? null : Math.round(progress.progress * 100); - } } - -const resolveFirst = (value$: Observable): Promise => - new Promise((resolve, reject) => { - value$.pipe(take(1)).subscribe({ - next: resolve, - error: reject, - }); - }); diff --git a/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.html b/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.html new file mode 100644 index 00000000..f6369e0e --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.html @@ -0,0 +1,8 @@ +

    edit.adminInstructionsSyncElasticAssetsHeading

    +
    + +
    {{ syncProgress }}%
    +
    +

    edit.adminInstructionsSyncElasticAssets

    diff --git a/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.scss b/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.scss new file mode 100644 index 00000000..6d284e43 --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.scss @@ -0,0 +1,31 @@ +@use "../../styles/variables" as variables; + +// asset-sync +.asset-sync { + display: flex; +} + +.asset-sync.active > button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.asset-sync > .progress { + display: flex; + align-items: center; + padding-inline: 0.25rem; + + border: 2px solid variables.$cyan-09; + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + + transition: 250ms ease-in; + transition-property: opacity, transform; + transform-origin: left; +} + +.asset-sync:not(.active) > .progress { + opacity: 0; + transform: scaleX(0); +} diff --git a/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.ts b/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.ts new file mode 100644 index 00000000..096218b2 --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-sync/asset-editor-sync.component.ts @@ -0,0 +1,52 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, OnInit } from '@angular/core'; +import { Observable, take } from 'rxjs'; + +@Component({ + selector: 'asset-sg-editor-sync', + templateUrl: './asset-editor-sync.component.html', + styleUrls: ['./asset-editor-sync.component.scss'], +}) +export class AssetEditorSyncComponent implements OnInit { + private _httpClient = inject(HttpClient); + + syncProgress: number | null = null; + + ngOnInit() { + void this.refreshAssetSyncProgress().then(() => { + if (this.syncProgress != null) { + void this.loopAssetSyncProgress(); + } + }); + } + + async synchronizeElastic() { + if (this.syncProgress !== null) { + return; + } + this.syncProgress = 0; + await resolveFirst(this._httpClient.post('/api/assets/sync', null)); + await this.loopAssetSyncProgress(); + } + + private async loopAssetSyncProgress() { + while (this.syncProgress !== null) { + await this.refreshAssetSyncProgress(); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + private async refreshAssetSyncProgress() { + type Progress = { progress: number } | null | undefined; + const progress = await resolveFirst(this._httpClient.get('/api/assets/sync')); + this.syncProgress = progress == null ? null : Math.round(progress.progress * 100); + } +} + +const resolveFirst = (value$: Observable): Promise => + new Promise((resolve, reject) => { + value$.pipe(take(1)).subscribe({ + next: resolve, + error: reject, + }); + }); diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.html b/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.html index 578fba55..36ab673a 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.html +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.html @@ -64,7 +64,7 @@ {{ statusWorkItem | valueItemName }} diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.ts b/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.ts index 45c3d44e..613c1c2e 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-administration/asset-editor-tab-administration.component.ts @@ -1,10 +1,14 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; -import { FormBuilder, FormGroupDirective } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnInit, Output } from '@angular/core'; +import { FormGroupDirective } from '@angular/forms'; import { fromAppShared } from '@asset-sg/client-shared'; +import { isNotNull } from '@asset-sg/core'; import { DateId } from '@asset-sg/shared'; +import { isMasterEditor } from '@asset-sg/shared/v2'; +import * as RD from '@devexperts/remote-data-ts'; +import { Store } from '@ngrx/store'; import { RxState } from '@rx-angular/state'; import * as O from 'fp-ts/Option'; -import { Observable } from 'rxjs'; +import { filter, map, Observable, withLatestFrom } from 'rxjs'; import { AssetEditDetailVM } from '../../models'; import { AssetEditorAdministrationFormGroup, AssetEditorFormGroup } from '../asset-editor-form-group'; @@ -36,7 +40,6 @@ const initialTabAdministrationState: TabAdministrationState = { export class AssetEditorTabAdministrationComponent implements OnInit { public _rootFormGroupDirective = inject(FormGroupDirective); public _rootFormGroup = this._rootFormGroupDirective.control as AssetEditorFormGroup; - private _formBuilder = inject(FormBuilder); private _state = inject>(RxState); public _form!: AssetEditorAdministrationFormGroup; @@ -44,6 +47,18 @@ export class AssetEditorTabAdministrationComponent implements OnInit { public _referenceDataVM$ = this._state.select('referenceDataVM'); public _assetEditDetail$ = this._state.select('assetEditDetail'); + private readonly filteredAssetEditDetail$ = this._state + .select('assetEditDetail') + .pipe(map(O.toNullable), filter(isNotNull)); + + private readonly store = inject(Store); + public readonly isMasterEditor$ = this.store.select(fromAppShared.selectRDUserProfile).pipe( + map(RD.toNullable), + filter(isNotNull), + withLatestFrom(this.filteredAssetEditDetail$), + map(([user, assetEditDetail]) => isMasterEditor(user, assetEditDetail.workgroupId)) + ); + // eslint-disable-next-line @angular-eslint/no-output-rename @Output('save') public save$ = new EventEmitter(); diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.html b/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.html index 87985584..90a79a54 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.html +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.html @@ -2,6 +2,16 @@
    +
    workgroup.title
    + + workgroup.title + + + {{ workgroup.name }} + + + +
    edit.tabs.general.title
    edit.tabs.general.publicTitle diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.ts b/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.ts index d1f9836b..c5f605af 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.ts @@ -3,7 +3,9 @@ import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit, ViewChil import { FormBuilder, FormControl, FormGroupDirective } from '@angular/forms'; import { fromAppShared } from '@asset-sg/client-shared'; import { eqAssetLanguageEdit } from '@asset-sg/shared'; +import { Role } from '@asset-sg/shared/v2'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { Store } from '@ngrx/store'; import { RxState } from '@rx-angular/state'; import * as O from 'fp-ts/Option'; import { @@ -106,6 +108,15 @@ export class AssetEditorTabGeneralComponent implements OnInit { ); public _currentlyEditedIdIndex$ = this._state.select('currentlyEditedIdIndex'); + private store = inject(Store); + + /** + * The workgroups to which the user is allowed to assign an asset. + */ + public availableWorkgroups$ = this.store + .select(fromAppShared.selectWorkgroups) + .pipe(map((workgroups) => workgroups.filter((it) => it.role != Role.Viewer))); + @Input() public set referenceDataVM$(value: Observable) { this._state.connect('referenceDataVM', value); @@ -176,12 +187,6 @@ export class AssetEditorTabGeneralComponent implements OnInit { this._ngOnInit$.next(); } - public _removeManCatLabelRef(value: string) { - this._form.controls['manCatLabelRefs'].setValue( - this._form.controls['manCatLabelRefs'].value.filter((v: string) => v !== value) - ); - } - public _insertNewIdClicked() { this._state.set({ userInsertMode: true, currentlyEditedIdIndex: -1 }); this.idForm.reset(); diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-page/asset-editor-tab-page.component.html b/libs/asset-editor/src/lib/components/asset-editor-tab-page/asset-editor-tab-page.component.html index 1dcd3b81..2775c308 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-page/asset-editor-tab-page.component.html +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-page/asset-editor-tab-page.component.html @@ -6,7 +6,7 @@ - + ({ ...file, willBeDeleted: false })), + workgroupId: asset.workgroupId, }, usage: { publicUse: asset.publicUse.isAvailable, @@ -284,6 +285,8 @@ export class AssetEditorTabPageComponent { newStatusWorkItemCode: O.fromNullable(this._form.getRawValue().administration.newStatusWorkItemCode), assetMainId: O.fromNullable(this._form.getRawValue().references.assetMain?.assetId), siblingAssetIds: this._form.getRawValue().references.siblingAssets.map((asset) => asset.assetId), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + workgroupId: this._form.getRawValue().general.workgroupId!, }; this._showProgressBar$.next(true); if (this._form.getRawValue().general.id === 0) { diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.html b/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.html index 5dc69d66..eb4e113d 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.html +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.html @@ -12,18 +12,13 @@ edit.tabs.usage.status - + {{ vm.statusAssetUseItems["tobechecked"] | valueItemName }} - + {{ vm.statusAssetUseItems["underclarification"] | valueItemName }} - + {{ vm.statusAssetUseItems["approved"] | valueItemName }} @@ -49,18 +44,13 @@ Status - + {{ vm.statusAssetUseItems["tobechecked"] | valueItemName }} - + {{ vm.statusAssetUseItems["underclarification"] | valueItemName }} - + {{ vm.statusAssetUseItems["approved"] | valueItemName }} diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.ts b/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.ts index 8e0c49af..0c7da25a 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-usage/asset-editor-tab-usage.component.ts @@ -3,19 +3,27 @@ import { ChangeDetectionStrategy, Component, Input, OnInit, TemplateRef, ViewChi import { FormGroupDirective } from '@angular/forms'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { LifecycleHooks, LifecycleHooksDirective, fromAppShared } from '@asset-sg/client-shared'; +import { isNotNull } from '@asset-sg/core'; +import { isMasterEditor } from '@asset-sg/shared/v2'; +import * as RD from '@devexperts/remote-data-ts'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { RxState } from '@rx-angular/state'; -import { Observable, map, of, startWith, switchMap } from 'rxjs'; +import * as O from 'fp-ts/Option'; +import { Observable, map, of, startWith, switchMap, withLatestFrom, filter } from 'rxjs'; +import { AssetEditDetailVM } from '../../models'; import { AssetEditorFormGroup, AssetEditorUsageFormGroup } from '../asset-editor-form-group'; interface AssetEditorTabUsageState { referenceDataVM: fromAppShared.ReferenceDataVM; + assetEditDetail: O.Option; } const initialAssetEditorTabUsageState: AssetEditorTabUsageState = { referenceDataVM: fromAppShared.emptyReferenceDataVM, + assetEditDetail: O.none, }; @UntilDestroy() @@ -43,6 +51,18 @@ export class AssetEditorTabUsageComponent implements OnInit { private _dialogRefRemoveNationalInterestDialog?: DialogRef; + private readonly filteredAssetEditDetail$ = this._state + .select('assetEditDetail') + .pipe(map(O.toNullable), filter(isNotNull)); + + private readonly store = inject(Store); + public readonly isMasterEditor$ = this.store.select(fromAppShared.selectRDUserProfile).pipe( + map(RD.toNullable), + filter(isNotNull), + withLatestFrom(this.filteredAssetEditDetail$), + map(([user, assetEditDetail]) => isMasterEditor(user, assetEditDetail.workgroupId)) + ); + @ViewChild('tmplRemoveNationalInterestDialog') private _tmplRemoveNationalInterestDialog!: TemplateRef; @Input() @@ -50,6 +70,11 @@ export class AssetEditorTabUsageComponent implements OnInit { this._state.connect('referenceDataVM', value); } + @Input() + public set assetEditDetail$(value: Observable>) { + this._state.connect('assetEditDetail', value); + } + private _form$ = this._lc.onInit$.pipe(map(() => this.rootFormGroup.controls['usage'])); public _internalStartAvailabilityDateErrorText$ = this._form$.pipe( @@ -137,12 +162,6 @@ export class AssetEditorTabUsageComponent implements OnInit { } } - public _removeNatRelTypeItemCode(value: string) { - this._form.controls['natRelTypeItemCodes'].setValue( - this._form.controls['natRelTypeItemCodes'].value.filter((v: string) => v !== value) - ); - } - public getUsageErrorText(errors: null | { internalPublicUsageDateError?: true }) { return errors && errors.internalPublicUsageDateError ? this._translateService.get('edit.tabs.usage.validationErrors.internalPublicUsageDateError') diff --git a/libs/asset-editor/src/lib/services/asset-editor.service.ts b/libs/asset-editor/src/lib/services/asset-editor.service.ts index b39ae7f6..66d7150e 100644 --- a/libs/asset-editor/src/lib/services/asset-editor.service.ts +++ b/libs/asset-editor/src/lib/services/asset-editor.service.ts @@ -27,7 +27,7 @@ export class AssetEditorService { public createAsset(patchAsset: PatchAsset): ORD.ObservableRemoteData { return this._httpClient - .put(`/api/asset-edit`, PatchAsset.encode(patchAsset)) + .post(`/api/asset-edit`, PatchAsset.encode(patchAsset)) .pipe( map(flow(AssetEditDetail.decode, E.mapLeft(decodeError))), OE.catchErrorW(httpErrorResponseError), @@ -41,7 +41,7 @@ export class AssetEditorService { patchAsset: PatchAsset ): ORD.ObservableRemoteData { return this._httpClient - .patch(`/api/asset-edit/${assetId}`, PatchAsset.encode(patchAsset)) + .put(`/api/asset-edit/${assetId}`, PatchAsset.encode(patchAsset)) .pipe( map(flow(AssetEditDetail.decode, E.mapLeft(decodeError))), OE.catchErrorW(httpErrorResponseError), @@ -55,7 +55,7 @@ export class AssetEditorService { ? forkJoin( fileIds.map((fileId) => { return this._httpClient - .delete(`/api/asset-edit/${assetId}/file/${fileId}`) + .delete(`/api/files/${fileId}`) .pipe(map(E.right), OE.catchErrorW(httpErrorResponseError), map(RD.fromEither), startWith(RD.pending)); }) ).pipe( @@ -76,8 +76,9 @@ export class AssetEditorService { ...files.map((file) => { const formData = new FormData(); formData.append('file', file); + formData.append('assetId', `${assetId}`); return this._httpClient - .post(`/api/asset-edit/${assetId}/file`, formData) + .post(`/api/files`, formData) .pipe(map(E.right), OE.catchErrorW(httpErrorResponseError)); }) ).pipe( @@ -96,7 +97,7 @@ export class AssetEditorService { public updateContact(contactId: number, patchContact: PatchContact): ORD.ObservableRemoteData { return this._httpClient - .patch(`/api/contact-edit/${contactId}`, PatchContact.encode(patchContact)) + .put(`/api/contacts/${contactId}`, PatchContact.encode(patchContact)) .pipe( map(flow(Contact.decode, E.mapLeft(decodeError))), OE.catchErrorW(httpErrorResponseError), @@ -107,7 +108,7 @@ export class AssetEditorService { public createContact(patchContact: PatchContact): ORD.ObservableRemoteData { return this._httpClient - .put(`/api/contact-edit`, PatchContact.encode(patchContact)) + .post(`/api/contacts`, PatchContact.encode(patchContact)) .pipe( map(flow(Contact.decode, E.mapLeft(decodeError))), OE.catchErrorW(httpErrorResponseError), diff --git a/libs/asset-viewer/src/lib/asset-viewer.module.ts b/libs/asset-viewer/src/lib/asset-viewer.module.ts index c2bb03ee..2e41a609 100644 --- a/libs/asset-viewer/src/lib/asset-viewer.module.ts +++ b/libs/asset-viewer/src/lib/asset-viewer.module.ts @@ -28,12 +28,13 @@ import { AnchorComponent, AnimateNumberComponent, ButtonComponent, + CanCreateDirective, + CanUpdateDirective, DatepickerToggleIconComponent, DatePipe, DragHandleComponent, DrawerComponent, DrawerPanelComponent, - IsEditorPipe, SmartTranslatePipe, ValueItemDescriptionPipe, ValueItemNamePipe, @@ -88,7 +89,6 @@ import { assetSearchReducer } from './state/asset-search/asset-search.reducer'; ValueItemDescriptionPipe, DatePipe, ZoomControlsComponent, - IsEditorPipe, ValueItemNamePipe, ForModule, @@ -123,6 +123,8 @@ import { assetSearchReducer } from './state/asset-search/asset-search.reducer'; SmartTranslatePipe, CdkMonitorFocus, MatTooltip, + CanCreateDirective, + CanUpdateDirective, ], providers: [ TranslatePipe, diff --git a/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.html b/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.html index 2d0f6cad..4553224a 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.html +++ b/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.html @@ -18,182 +18,180 @@
    - - -
    -
    - + + -
    - + +
    +
    + + + + + - - - - - - - + + -
    - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    -
      - +
    +
    {{ assetDetail.assetKindItem | valueItemName }}
    {{ assetDetail.createDate | assetSgDate }}
    + + {{ id.id + " [" + id.description + "]" }} +
    + +
      +
    • + {{ "contactRoles." + contact.role | translate }}:
      + {{ contact.name }}
      + {{ contact.locality }}
      + {{ contact.contactKindItem | valueItemName }} +
    • +
    +
    + + + {{ (manCatLabel | valueItemName) + (last ? "" : ", ") }} + +
    +
      +
    • {{ language | valueItemName }}
    • +
    +
    {{ assetDetail.assetFormatItem | valueItemName }}
    + + + {{ (assetFormatComposition | valueItemName) + (last ? "" : ", ") }} + +
    + + {{ + typeNatRel | valueItemName + }} +
    + + +
    {{ assetDetail.lastProcessedDate | assetSgDate }}
    + + + + + + + +
    {{ statusWork.statusWorkDate | assetSgDate }}{{ statusWork.statusWork | valueItemName }}
    +
    diff --git a/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.ts b/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.ts index bc8756a9..1e56cec0 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Component, inject } from '@angular/core'; import { AppState } from '@asset-sg/client-shared'; import { AssetFile } from '@asset-sg/shared'; +import { AssetEditPolicy } from '@asset-sg/shared/v2'; import { Store } from '@ngrx/store'; import * as actions from '../../state/asset-search/asset-search.actions'; @@ -38,7 +39,7 @@ export class AssetSearchDetailComponent { downloadFile(file: Omit, isDownload = true): void { this.activeFileDownloads.set(file.fileId, { isDownload }); - this.httpClient.get(`/api/file/${file.fileId}`, { responseType: 'blob' }).subscribe({ + this.httpClient.get(`/api/files/${file.fileId}`, { responseType: 'blob' }).subscribe({ next: (blob) => { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); @@ -63,4 +64,6 @@ export class AssetSearchDetailComponent { }, }); } + + protected readonly AssetEditPolicy = AssetEditPolicy; } diff --git a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts b/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts index 5cae871f..e2bd230f 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts @@ -9,7 +9,7 @@ import { Filter } from '../../state/asset-search/asset-search.selector'; templateUrl: './asset-search-filter-list.component.html', styleUrl: './asset-search-filter-list.component.scss', }) -export class AssetSearchFilterListComponent { +export class AssetSearchFilterListComponent { @Input({ required: true }) filters!: Array>; diff --git a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html index 370ab92c..a389c04b 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html +++ b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html @@ -9,6 +9,10 @@

    search.searchControl

    search.refineSearch

    +
    +

    search.workgroup

    + +

    search.usage

    diff --git a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts index a790f674..872938f6 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts @@ -18,6 +18,7 @@ import { selectLanguageFilters, selectManCatLabelFilters, selectUsageCodeFilters, + selectWorkgroupFilters, } from '../../state/asset-search/asset-search.selector'; const MIN_CREATE_DATE = new Date(1800, 0, 1); @@ -51,6 +52,7 @@ export class AssetSearchRefineComponent implements OnInit, OnDestroy, AfterViewI readonly manCatLabelFilters$ = this.store.select(selectManCatLabelFilters); readonly languageFilters$ = this.store.select(selectLanguageFilters); readonly assetKindFilters$ = this.store.select(selectAssetKindFilters); + readonly workgroupFilters$ = this.store.select(selectWorkgroupFilters); private readonly subscriptions: Subscription = new Subscription(); diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts index 1d1911df..f15f14df 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, ElementRef, EventEmitter, @@ -53,6 +54,7 @@ export class AssetSearchResultsComponent implements OnInit, OnDestroy { public readonly pageStats$ = this._store.select(selectAssetSearchPageData); public readonly currentAssetDetail$ = this._store.select(selectCurrentAssetDetail); private readonly subscriptions: Subscription = new Subscription(); + private changeDetector = inject(ChangeDetectorRef); public ngOnInit() { this.initSubscriptions(); @@ -92,6 +94,7 @@ export class AssetSearchResultsComponent implements OnInit, OnDestroy { if (this.scrollContainer) { this.scrollContainer.nativeElement.scrollTop = 0; } + this.changeDetector.markForCheck(); }) ); } diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts index 147516a2..9976bdd8 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts @@ -53,8 +53,9 @@ const initialState: AssetSearchState = { assetKindItemCodes: [], languageItemCodes: [], geometryCodes: [], - usageCodes: [], manCatLabelItemCodes: [], + usageCodes: [], + workgroupIds: [], createDate: null, }, isMapInitialised: false, diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts index 2e3ba21b..798d4539 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts @@ -21,6 +21,7 @@ import { ValueCount, ValueItem, } from '@asset-sg/shared'; +import { SimpleWorkgroup, WorkgroupId } from '@asset-sg/shared/v2'; import * as RD from '@devexperts/remote-data-ts'; import { createSelector } from '@ngrx/store'; import * as A from 'fp-ts/Array'; @@ -138,7 +139,7 @@ export const selectAvailableAuthors = createSelector( export const selectCreateDate = createSelector(selectAssetsSearchStats, (stats): DateRange | null => stats.createDate); -const makeFilters = ( +const makeFilters = ( configs: Array>, counts: Array>, activeValues: T[] | undefined, @@ -147,7 +148,7 @@ const makeFilters = ( return configs.map((filter) => makeFilter(filter, activeValues, counts, queryKey)); }; -const makeFilter = ( +const makeFilter = ( filter: FilterConfig, activeValues: T[] | undefined, counts: Array>, @@ -195,6 +196,46 @@ export const selectFilters = ( } ); +export const selectWorkgroupFilters = createSelector( + fromAppShared.selectWorkgroups, + selectAssetSearchQuery, + selectAssetsSearchStats, + (workgroups, query, stats) => { + // Create a mapping of workgroups by their id for easier and more performant lookup. + const workgroupsById = new Map(); + for (const workgroup of workgroups) { + workgroupsById.set(workgroup.id, workgroup); + } + + // Map the workgroups with stats to filters. + const configs: FilterConfig[] = []; + for (const stat of stats.workgroupIds) { + const workgroup = workgroupsById.get(stat.value); + if (workgroup == null) { + continue; + } + workgroupsById.delete(stat.value); + configs.push({ + name: workgroup.name, + value: workgroup.id, + }); + } + + // Include workgroups with no assigned assets. + for (const workgroup of workgroupsById.values()) { + configs.push({ + name: workgroup.name, + value: workgroup.id, + }); + } + + // Sort the filters so their orders stays consistent. + configs.sort((a, b) => (a.name as string).localeCompare(b.name as string)); + + return makeFilters(configs, stats.workgroupIds, query.workgroupIds, 'workgroupIds'); + } +); + export const selectUsageCodeFilters = selectFilters('usageCodes', () => usageCodes.map((code) => ({ name: { key: `search.usageCode.${code}` }, @@ -248,7 +289,7 @@ export interface FullContact extends Contact { role?: AssetContactRole; } -export interface Filter { +export interface Filter { name: Translation; value: T; @@ -270,7 +311,7 @@ export interface Filter { queryKey: keyof AssetSearchQuery; } -type FilterConfig = Pick, 'name' | 'value'>; +type FilterConfig = Pick, 'name' | 'value'>; export const makeTranslatedValueFromItemName = (item: ValueItem): TranslatedValue => ({ de: item.nameDe, diff --git a/libs/auth/src/lib/auth.module.ts b/libs/auth/src/lib/auth.module.ts index 91d70381..2681b5a8 100644 --- a/libs/auth/src/lib/auth.module.ts +++ b/libs/auth/src/lib/auth.module.ts @@ -5,7 +5,6 @@ import { NgModule } from '@angular/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { RouterModule } from '@angular/router'; import { AnchorComponent, ButtonComponent, icons } from '@asset-sg/client-shared'; import { provideSvgIcons } from '@ngneat/svg-icon'; import { TranslateModule } from '@ngx-translate/core'; diff --git a/libs/auth/src/lib/services/auth.interceptor.ts b/libs/auth/src/lib/services/auth.interceptor.ts index 3cf03058..fc8c6c3d 100644 --- a/libs/auth/src/lib/services/auth.interceptor.ts +++ b/libs/auth/src/lib/services/auth.interceptor.ts @@ -1,17 +1,34 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router'; import { AlertType, showAlert } from '@asset-sg/client-shared'; import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { OAuthService } from 'angular-oauth2-oidc'; -import { EMPTY, Observable, catchError, from, switchMap } from 'rxjs'; +import { catchError, EMPTY, from, Observable, Subscription, switchMap } from 'rxjs'; import { AuthService, AuthState } from './auth.service'; @Injectable() -export class AuthInterceptor implements HttpInterceptor { +export class AuthInterceptor implements HttpInterceptor, OnDestroy { private _oauthService = inject(OAuthService); private readonly store = inject(Store); private readonly authService = inject(AuthService); + private readonly translateService = inject(TranslateService); + private readonly router = inject(Router); + + private readonly subscription = new Subscription(); + + /** + * Whether the router is currently in the middle of a navigation. + * If this is `false`, then the site is fully loaded and the user is able to interact with it. + * @private + */ + private isNavigating = false; + + constructor() { + this.initializeRouterSubscription(); + } intercept(req: HttpRequest, next: HttpHandler): Observable> { const token = sessionStorage.getItem('access_token'); @@ -41,7 +58,32 @@ export class AuthInterceptor implements HttpInterceptor { private async handleError(error: HttpErrorResponse): Promise { switch (error.status) { case 403: - this.authService.setState(AuthState.Forbidden); + if ( + error.error == 'not authorized by eIAM' || + (typeof error.error === 'object' && error.error != null && error.error.error === 'not authorized by eIAM') + ) { + // The initial logging via eIAM was successful, + // but the user does not have access to this application. + this.authService.setState(AuthState.AccessForbidden); + } else if (this.isNavigating) { + // The user attempted to navigate to a page to which they have no access. + // A common way this happens is by manually accessing a forbidden URL. + this.authService.setState(AuthState.ForbiddenResource); + } else { + // The user attempted to load a resource to which they have no access. + // This mainly happens when there's a difference between two databases (e.g. Postgres and Elastic), + // causing the user to be able to request resources they should not be able to. + this.store.dispatch( + showAlert({ + alert: { + id: `resource-forbidden`, + text: this.translateService.instant('resourceForbidden'), + type: AlertType.Error, + isPersistent: false, + }, + }) + ); + } break; case 401: this.store.dispatch( @@ -75,4 +117,32 @@ export class AuthInterceptor implements HttpInterceptor { } } } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + private initializeRouterSubscription(): void { + this.subscription.add( + this.router.events.subscribe((event) => { + if (event instanceof NavigationStart) { + this.isNavigating = true; + this.resetAuthState(); + } else if ( + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError + ) { + this.isNavigating = false; + this.resetAuthState(); + } + }) + ); + } + + private resetAuthState(): void { + if (this.authService.isLoggedIn() && this.authService.state === AuthState.ForbiddenResource) { + this.authService.setState(AuthState.Success); + } + } } diff --git a/libs/auth/src/lib/services/auth.service.ts b/libs/auth/src/lib/services/auth.service.ts index 07aeeece..549b6927 100644 --- a/libs/auth/src/lib/services/auth.service.ts +++ b/libs/auth/src/lib/services/auth.service.ts @@ -1,11 +1,12 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { ApiError, httpErrorResponseOrUnknownError } from '@asset-sg/client-shared'; -import { OE, ORD, decode } from '@asset-sg/core'; -import { User } from '@asset-sg/shared'; +import { inject, Injectable } from '@angular/core'; +import { ApiError } from '@asset-sg/client-shared'; +import { ORD } from '@asset-sg/core'; +import { User, UserSchema } from '@asset-sg/shared/v2'; import * as RD from '@devexperts/remote-data-ts'; import { OAuthService } from 'angular-oauth2-oidc'; -import { BehaviorSubject, Observable, map, startWith } from 'rxjs'; +import { plainToInstance } from 'class-transformer'; +import { BehaviorSubject, map, Observable, startWith } from 'rxjs'; import urlJoin from 'url-join'; @Injectable({ providedIn: 'root' }) @@ -14,7 +15,7 @@ export class AuthService { private oauthService = inject(OAuthService); - private state = new BehaviorSubject(AuthState.Ongoing); + private _state = new BehaviorSubject(AuthState.Ongoing); public configureOAuth( issuer: string, @@ -38,31 +39,42 @@ export class AuthService { async signIn(): Promise { try { - if (this.state.value === AuthState.Ongoing) { + if (this._state.value === AuthState.Ongoing) { const success = await this.oauthService.loadDiscoveryDocumentAndLogin(); if (success) { this.oauthService.setupAutomaticSilentRefresh(); - this.state.next(AuthState.Success); + + // If something else has interrupted the auth process, then we don't want to signal a success. + if (this._state.value === AuthState.Ongoing) { + this._state.next(AuthState.Success); + } } } else { - this.state.next(AuthState.Ongoing); + this._state.next(AuthState.Ongoing); this.oauthService.initLoginFlow(); } } catch (e) { - this.state.next(AuthState.Aborted); + this._state.next(AuthState.Aborted); } } + get state(): AuthState { + return this._state.value; + } + get state$(): Observable { - return this.state.asObservable(); + return this._state.asObservable(); } setState(state: AuthState): void { - this.state.next(state); + this._state.next(state); } getUserProfile(): ORD.ObservableRemoteData { - return this._getUserProfile().pipe(map(RD.fromEither), startWith(RD.pending)); + return this._getUserProfile().pipe( + map((it) => RD.success(it)), + startWith(RD.pending) + ); } isLoggedIn(): boolean { @@ -77,8 +89,8 @@ export class AuthService { }); } - private _getUserProfile() { - return this._httpClient.get('/api/user').pipe(map(decode(User)), OE.catchErrorW(httpErrorResponseOrUnknownError)); + private _getUserProfile(): Observable { + return this._httpClient.get('/api/users/current').pipe(map((it) => plainToInstance(UserSchema, it))); } buildAuthUrl = (path: string) => urlJoin(`/auth`, path); @@ -87,6 +99,7 @@ export class AuthService { export enum AuthState { Ongoing, Aborted, - Forbidden, + AccessForbidden, + ForbiddenResource, Success, } diff --git a/libs/client-shared/src/index.ts b/libs/client-shared/src/index.ts index 91777201..79691443 100644 --- a/libs/client-shared/src/index.ts +++ b/libs/client-shared/src/index.ts @@ -17,3 +17,9 @@ export * from './lib/features/alert/alert.model'; export * from './lib/features/alert/alert.module'; export * from './lib/features/alert/alert.reducer'; export * from './lib/features/alert/alert.selectors'; + +export * from './lib/directives/admin-only.directive'; +export * from './lib/directives/can-create.directive'; +export * from './lib/directives/can-delete.directive'; +export * from './lib/directives/can-show.directive'; +export * from './lib/directives/can-update.directive'; diff --git a/libs/client-shared/src/lib/components/auth-pipes/base-auth-pipe.ts b/libs/client-shared/src/lib/components/auth-pipes/base-auth-pipe.ts deleted file mode 100644 index d5b14bdc..00000000 --- a/libs/client-shared/src/lib/components/auth-pipes/base-auth-pipe.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ChangeDetectorRef, PipeTransform, inject } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; - -import { AppState } from '../../state'; - -export abstract class BaseAuthPipe implements PipeTransform { - private _store = inject(Store); - private _cdRef = inject(ChangeDetectorRef); - - private _value: boolean; - - constructor(initialValue: boolean, selector: (store: Store) => Observable) { - this._value = initialValue; - selector(this._store).subscribe((value) => { - this._value = value; - this._cdRef.markForCheck(); - }); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - transform(_: null): boolean { - return this._value; - } -} diff --git a/libs/client-shared/src/lib/components/auth-pipes/index.ts b/libs/client-shared/src/lib/components/auth-pipes/index.ts deleted file mode 100644 index 98abe63f..00000000 --- a/libs/client-shared/src/lib/components/auth-pipes/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './is-editor.pipe'; -export * from './is-not-master-editor.pipe'; diff --git a/libs/client-shared/src/lib/components/auth-pipes/is-editor.pipe.ts b/libs/client-shared/src/lib/components/auth-pipes/is-editor.pipe.ts deleted file mode 100644 index a6950a05..00000000 --- a/libs/client-shared/src/lib/components/auth-pipes/is-editor.pipe.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -import { fromAppShared } from '../../state'; - -import { BaseAuthPipe } from './base-auth-pipe'; - -@Pipe({ - standalone: true, - name: 'isEditor', - pure: false, -}) -export class IsEditorPipe extends BaseAuthPipe implements PipeTransform { - constructor() { - super(false, (store) => store.select(fromAppShared.selectIsEditor)); - } -} diff --git a/libs/client-shared/src/lib/components/auth-pipes/is-not-master-editor.pipe.ts b/libs/client-shared/src/lib/components/auth-pipes/is-not-master-editor.pipe.ts deleted file mode 100644 index 444608c6..00000000 --- a/libs/client-shared/src/lib/components/auth-pipes/is-not-master-editor.pipe.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -import { fromAppShared } from '../../state'; - -import { BaseAuthPipe } from './base-auth-pipe'; - -@Pipe({ - standalone: true, - name: 'isNotMasterEditor', - pure: false, -}) -export class IsNotMasterEditorPipe extends BaseAuthPipe implements PipeTransform { - constructor() { - super(true, (store) => store.select(fromAppShared.selectIsNotMasterEditor)); - } -} diff --git a/libs/client-shared/src/lib/components/index.ts b/libs/client-shared/src/lib/components/index.ts index 37664402..9b2bf182 100644 --- a/libs/client-shared/src/lib/components/index.ts +++ b/libs/client-shared/src/lib/components/index.ts @@ -1,5 +1,4 @@ export * from './animate-number'; -export * from './auth-pipes'; export * from './button'; export * from './date'; export * from './datepicker-toggle-icon'; diff --git a/libs/client-shared/src/lib/components/smart-translate.pipe.ts b/libs/client-shared/src/lib/components/smart-translate.pipe.ts index fa305e0e..4d447abb 100644 --- a/libs/client-shared/src/lib/components/smart-translate.pipe.ts +++ b/libs/client-shared/src/lib/components/smart-translate.pipe.ts @@ -32,7 +32,10 @@ export class SmartTranslatePipe implements PipeTransform, OnDestroy { this.subscription.unsubscribe(); } - transform(value: TranslationKey | TranslatedValue): string { + transform(value: Translation): string { + if (typeof value === 'string') { + return value; + } if (isTranslationKey(value)) { return this.translationPipe.transform(value.key); } @@ -40,7 +43,7 @@ export class SmartTranslatePipe implements PipeTransform, OnDestroy { } } -export type Translation = TranslationKey | TranslatedValue; +export type Translation = TranslationKey | TranslatedValue | string; export interface TranslationKey { key: string; diff --git a/libs/client-shared/src/lib/directives/admin-only.directive.ts b/libs/client-shared/src/lib/directives/admin-only.directive.ts new file mode 100644 index 00000000..00764e84 --- /dev/null +++ b/libs/client-shared/src/lib/directives/admin-only.directive.ts @@ -0,0 +1,39 @@ +import { ChangeDetectorRef, Directive, inject, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core'; +import * as RD from '@devexperts/remote-data-ts'; +import { Store } from '@ngrx/store'; +import { map, Subscription } from 'rxjs'; +import { AppState, fromAppShared } from '../state'; + +@Directive({ + selector: '[adminOnly]', + standalone: true, +}) +export class AdminOnlyDirective implements OnInit, OnDestroy { + private store = inject(Store); + + private readonly subscription = new Subscription(); + + private ref = inject(ChangeDetectorRef); + + constructor(private readonly templateRef: TemplateRef, private readonly viewContainer: ViewContainerRef) {} + + ngOnInit(): void { + this.subscription.add( + this.store + .select(fromAppShared.selectRDUserProfile) + .pipe(map(RD.toNullable)) + .subscribe((user) => { + if (user != null && user.isAdmin) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + this.ref.markForCheck(); + }) + ); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/libs/client-shared/src/lib/directives/base-policy.directive.ts b/libs/client-shared/src/lib/directives/base-policy.directive.ts new file mode 100644 index 00000000..34bd6764 --- /dev/null +++ b/libs/client-shared/src/lib/directives/base-policy.directive.ts @@ -0,0 +1,67 @@ +import { + ChangeDetectorRef, + Directive, + inject, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { User, Policy } from '@asset-sg/shared/v2'; +import * as RD from '@devexperts/remote-data-ts'; +import { Store } from '@ngrx/store'; +import { map, Subscription } from 'rxjs'; +import { Class } from 'type-fest'; +import { AppState } from '../state/app-shared-state'; +import { selectRDUserProfile } from '../state/app-shared-state.selectors'; + +@Directive({ + standalone: true, +}) +export abstract class BasePolicyDirective implements OnInit, OnChanges, OnDestroy { + abstract get policy(): Class>; + + private readonly store = inject(Store); + + private user: User | null = null; + + private ref = inject(ChangeDetectorRef); + + private readonly subscription = new Subscription(); + + constructor(private readonly templateRef: TemplateRef, private readonly viewContainer: ViewContainerRef) {} + + ngOnInit(): void { + this.subscription.add( + this.store + .select(selectRDUserProfile) + .pipe(map((user) => (RD.isSuccess(user) ? user.value : null))) + .subscribe((user) => { + this.user = user; + this.render(); + }) + ); + } + + ngOnChanges(_changes: SimpleChanges): void { + this.render(); + this.ref.markForCheck(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + private render(): void { + const policyInstance = this.user == null ? null : new this.policy(this.user); + if (policyInstance != null && (policyInstance.canDoEverything() || this.check(policyInstance))) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + } + + protected abstract check(policy: Policy): boolean; +} diff --git a/libs/client-shared/src/lib/directives/can-create.directive.ts b/libs/client-shared/src/lib/directives/can-create.directive.ts new file mode 100644 index 00000000..86030e88 --- /dev/null +++ b/libs/client-shared/src/lib/directives/can-create.directive.ts @@ -0,0 +1,17 @@ +import { Directive, Input } from '@angular/core'; +import { Policy } from '@asset-sg/shared/v2'; +import { Class } from 'type-fest'; +import { BasePolicyDirective } from './base-policy.directive'; + +@Directive({ + selector: '[canCreate]', + standalone: true, +}) +export class CanCreateDirective extends BasePolicyDirective { + @Input({ alias: 'canCreate', required: true }) + policy!: Class>; + + protected override check(policy: Policy): boolean { + return policy.canCreate(); + } +} diff --git a/libs/client-shared/src/lib/directives/can-delete.directive.ts b/libs/client-shared/src/lib/directives/can-delete.directive.ts new file mode 100644 index 00000000..692baaed --- /dev/null +++ b/libs/client-shared/src/lib/directives/can-delete.directive.ts @@ -0,0 +1,20 @@ +import { Directive, Input } from '@angular/core'; +import { Policy } from '@asset-sg/shared/v2'; +import { Class } from 'type-fest'; +import { BasePolicyDirective } from './base-policy.directive'; + +@Directive({ + selector: '[canDelete]', + standalone: true, +}) +export class CanDeleteDirective extends BasePolicyDirective { + @Input({ alias: 'canDelete', required: true }) + policy!: Class>; + + @Input({ alias: 'canDeleteWith', required: true }) + with!: T; + + protected override check(policy: Policy): boolean { + return policy.canDelete(this.with); + } +} diff --git a/libs/client-shared/src/lib/directives/can-show.directive.ts b/libs/client-shared/src/lib/directives/can-show.directive.ts new file mode 100644 index 00000000..67221970 --- /dev/null +++ b/libs/client-shared/src/lib/directives/can-show.directive.ts @@ -0,0 +1,20 @@ +import { Directive, Input } from '@angular/core'; +import { Policy } from '@asset-sg/shared/v2'; +import { Class } from 'type-fest'; +import { BasePolicyDirective } from './base-policy.directive'; + +@Directive({ + selector: '[canShow]', + standalone: true, +}) +export class CanShowDirective extends BasePolicyDirective { + @Input({ alias: 'canShow', required: true }) + policy!: Class>; + + @Input({ alias: 'canShowWith', required: true }) + with!: T; + + protected override check(policy: Policy): boolean { + return policy.canShow(this.with); + } +} diff --git a/libs/client-shared/src/lib/directives/can-update.directive.ts b/libs/client-shared/src/lib/directives/can-update.directive.ts new file mode 100644 index 00000000..6d52eda0 --- /dev/null +++ b/libs/client-shared/src/lib/directives/can-update.directive.ts @@ -0,0 +1,20 @@ +import { Directive, Input } from '@angular/core'; +import { Policy } from '@asset-sg/shared/v2'; +import { Class } from 'type-fest'; +import { BasePolicyDirective } from './base-policy.directive'; + +@Directive({ + selector: '[canUpdate]', + standalone: true, +}) +export class CanUpdateDirective extends BasePolicyDirective { + @Input({ alias: 'canUpdate', required: true }) + policy!: Class>; + + @Input({ alias: 'canUpdateWith', required: true }) + with!: T; + + protected override check(policy: Policy): boolean { + return policy.canUpdate(this.with); + } +} diff --git a/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html index da955956..7c8eabd7 100644 --- a/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html +++ b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html @@ -1,3 +1,3 @@ - +
  • diff --git a/libs/client-shared/src/lib/features/alert/alert.module.ts b/libs/client-shared/src/lib/features/alert/alert.module.ts index 85a9f216..0171f0b4 100644 --- a/libs/client-shared/src/lib/features/alert/alert.module.ts +++ b/libs/client-shared/src/lib/features/alert/alert.module.ts @@ -3,13 +3,14 @@ import { NgModule } from '@angular/core'; import { SvgIconComponent } from '@ngneat/svg-icon'; import { StoreModule } from '@ngrx/store'; +import { ForModule } from '@rx-angular/template/for'; import { AlertComponent } from './alert/alert.component'; import { AlertListComponent } from './alert-list/alert-list.component'; import { alertFeature, alertReducer } from './alert.reducer'; @NgModule({ declarations: [AlertListComponent, AlertComponent], - imports: [CommonModule, AsyncPipe, StoreModule.forFeature(alertFeature, alertReducer), SvgIconComponent], + imports: [CommonModule, AsyncPipe, StoreModule.forFeature(alertFeature, alertReducer), SvgIconComponent, ForModule], exports: [AlertListComponent], }) export class AlertModule {} diff --git a/libs/client-shared/src/lib/state/app-shared-state.actions.ts b/libs/client-shared/src/lib/state/app-shared-state.actions.ts index 4f0f8117..33fd9242 100644 --- a/libs/client-shared/src/lib/state/app-shared-state.actions.ts +++ b/libs/client-shared/src/lib/state/app-shared-state.actions.ts @@ -1,4 +1,5 @@ -import { Contact, Lang, ReferenceData, User } from '@asset-sg/shared'; +import { Contact, Lang, ReferenceData } from '@asset-sg/shared'; +import { SimpleWorkgroup, User } from '@asset-sg/shared/v2'; import * as RD from '@devexperts/remote-data-ts'; import { createAction, props } from '@ngrx/store'; @@ -25,6 +26,12 @@ export const loadUserProfileResult = createAction( props>() ); +export const loadWorkgroups = createAction('[App Shared State] Load Workgroups'); +export const loadWorkgroupsResult = createAction( + '[App Shared State] Load Workgroups Result', + props<{ workgroups: SimpleWorkgroup[] }>() +); + export const openPanel = createAction('[App Shared State] Open Panel'); export const logout = createAction('[App Shared State] Logout'); diff --git a/libs/client-shared/src/lib/state/app-shared-state.selectors.ts b/libs/client-shared/src/lib/state/app-shared-state.selectors.ts index 18143ebc..a39a1f43 100644 --- a/libs/client-shared/src/lib/state/app-shared-state.selectors.ts +++ b/libs/client-shared/src/lib/state/app-shared-state.selectors.ts @@ -1,11 +1,4 @@ -import { - Contact, - emptyValueItem, - isEditor, - isMasterEditor, - ReferenceData, - valueItemRecordToArray, -} from '@asset-sg/shared'; +import { Contact, emptyValueItem, ReferenceData, valueItemRecordToArray } from '@asset-sg/shared'; import * as RD from '@devexperts/remote-data-ts'; import { createSelector } from '@ngrx/store'; import * as A from 'fp-ts/Array'; @@ -22,7 +15,11 @@ export const selectRDReferenceData = createSelector(appSharedFeature, (state) => export const selectRDUserProfile = createSelector(appSharedFeature, (state) => state.rdUserProfile); -const makeRefenceDataVM = (referenceData: ReferenceData) => ({ +export const selectUser = createSelector(selectRDUserProfile, RD.toNullable); + +export const selectWorkgroups = createSelector(appSharedFeature, (state) => state.workgroups); + +const makeReferenceDataVM = (referenceData: ReferenceData) => ({ ...referenceData, assetFormItemArray: valueItemRecordToArray(referenceData.assetFormatItems), assetKindItemArray: valueItemRecordToArray(referenceData.assetKindItems), @@ -39,7 +36,7 @@ const makeRefenceDataVM = (referenceData: ReferenceData) => ({ A.map((a) => a[1]) ), }); -export type ReferenceDataVM = ReturnType; +export type ReferenceDataVM = ReturnType; export const emptyReferenceDataVM: ReferenceDataVM = { assetFormatItems: {}, @@ -95,19 +92,3 @@ export const selectRDReferenceDataVM = createSelector( ); export const selectLocale = createSelector(appSharedFeature, (state) => (state.lang === 'en' ? 'en-GB' : 'de-CH')); - -export const selectIsNotMasterEditor = createSelector( - selectRDUserProfile, - flow( - RD.map((userProfile) => !isMasterEditor(userProfile)), - RD.getOrElse(() => true) - ) -); - -export const selectIsEditor = createSelector( - selectRDUserProfile, - flow( - RD.map((userProfile) => isEditor(userProfile)), - RD.getOrElse(() => false) - ) -); diff --git a/libs/client-shared/src/lib/state/app-shared-state.ts b/libs/client-shared/src/lib/state/app-shared-state.ts index f1336abc..fc8afb4c 100644 --- a/libs/client-shared/src/lib/state/app-shared-state.ts +++ b/libs/client-shared/src/lib/state/app-shared-state.ts @@ -1,4 +1,5 @@ -import { Lang, ReferenceData, User } from '@asset-sg/shared'; +import { Lang, ReferenceData } from '@asset-sg/shared'; +import { SimpleWorkgroup, User } from '@asset-sg/shared/v2'; import * as RD from '@devexperts/remote-data-ts'; import { ApiError } from '../utils'; @@ -6,6 +7,7 @@ import { ApiError } from '../utils'; export interface AppSharedState { rdUserProfile: RD.RemoteData; rdReferenceData: RD.RemoteData; + workgroups: SimpleWorkgroup[]; lang: Lang; } diff --git a/libs/client-shared/src/lib/utils/map.ts b/libs/client-shared/src/lib/utils/map.ts index c4287a80..dfdd22a2 100644 --- a/libs/client-shared/src/lib/utils/map.ts +++ b/libs/client-shared/src/lib/utils/map.ts @@ -12,7 +12,7 @@ import { LineString, Point, Polygon, SimpleGeometry } from 'ol/geom'; import { fromExtent as polygonFromExtent } from 'ol/geom/Polygon'; import Map from 'ol/Map'; import { fromLonLat } from 'ol/proj'; -import { Circle, Fill, Icon, RegularShape, Stroke, Style } from 'ol/style'; +import { Circle, Fill, RegularShape, Stroke, Style } from 'ol/style'; import View from 'ol/View'; import { isoWGSLat, isoWGSLng } from '../models'; diff --git a/libs/favourite/src/lib/services/favourite.service.spec.ts b/libs/favourite/src/lib/services/favourite.service.spec.ts index dd4126c2..99906ded 100644 --- a/libs/favourite/src/lib/services/favourite.service.spec.ts +++ b/libs/favourite/src/lib/services/favourite.service.spec.ts @@ -33,7 +33,7 @@ describe('FavouriteService', () => { expect(favourites).toEqual(dummyFavourites); }); - const request = httpMock.expectOne(`/api/user/favourite`); + const request = httpMock.expectOne(`/api/users/current/favorites`); expect(request.request.method).toBe('GET'); request.flush(dummyFavourites); }); diff --git a/libs/favourite/src/lib/services/favourite.service.ts b/libs/favourite/src/lib/services/favourite.service.ts index bb364532..53c78e5c 100644 --- a/libs/favourite/src/lib/services/favourite.service.ts +++ b/libs/favourite/src/lib/services/favourite.service.ts @@ -10,6 +10,6 @@ export class FavouriteService { constructor(private http: HttpClient) {} getFavourites(): Observable { - return this.http.get(`/api/user/favourite`); + return this.http.get(`/api/users/current/favorites`); } } diff --git a/libs/profile/src/lib/components/profile/profile.component.ts b/libs/profile/src/lib/components/profile/profile.component.ts index 196a4f57..a6500187 100644 --- a/libs/profile/src/lib/components/profile/profile.component.ts +++ b/libs/profile/src/lib/components/profile/profile.component.ts @@ -10,8 +10,7 @@ import { LifecycleHooksDirective, appSharedStateActions, } from '@asset-sg/client-shared'; -import { rdIsComplete } from '@asset-sg/core'; -import { User } from '@asset-sg/shared'; +import { User } from '@asset-sg/shared/v2'; import * as RD from '@devexperts/remote-data-ts'; import { Store } from '@ngrx/store'; import { RxState } from '@rx-angular/state'; @@ -56,7 +55,7 @@ export class ProfileComponent extends RxState { this.logoutClicked$.pipe(switchMap(async () => this._authService.logOut())).subscribe((rd) => { this._store.dispatch(appSharedStateActions.logout()); - this._router.navigate(['/']); + void this._router.navigate(['/']); }); } } diff --git a/libs/shared/src/lib/models/asset-edit.ts b/libs/shared/src/lib/models/asset-edit.ts index 37806b93..790fa8e7 100644 --- a/libs/shared/src/lib/models/asset-edit.ts +++ b/libs/shared/src/lib/models/asset-edit.ts @@ -87,7 +87,10 @@ export const BaseAssetEditDetail = { siblingYAssets: C.array(LinkedAsset), statusWorks: C.array(StatusWork), assetFiles: C.array(AssetFile), + workgroupId: C.number, }; +const base = C.struct(BaseAssetEditDetail); +export type BaseAssetEditDetail = C.TypeOf; export const AssetEditDetail = C.struct({ ...BaseAssetEditDetail, diff --git a/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts b/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts index 228d860f..9f5d360a 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts @@ -38,6 +38,10 @@ export class AssetSearchQueryDTO implements AssetSearchQuery { @IsOptional() languageItemCodes?: string[]; + @IsNumber({}, { each: true }) + @IsOptional() + workgroupIds?: number[]; + @IsOptional() @ValidateNested() @Type(() => PartialDateRangeDTO) diff --git a/libs/shared/src/lib/models/asset-search/asset-search-query.ts b/libs/shared/src/lib/models/asset-search/asset-search-query.ts index 2e05e927..38e59060 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-query.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-query.ts @@ -12,6 +12,7 @@ export interface AssetSearchQuery { usageCodes?: UsageCode[]; geometryCodes?: Array; languageItemCodes?: string[]; + workgroupIds?: number[]; } export enum GeometryCode { diff --git a/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts b/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts index 06b0f835..cd1696a2 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts @@ -18,8 +18,9 @@ export class AssetSearchStatsDTO implements AssetSearchStats { assetKindItemCodes!: ValueCount[]; languageItemCodes!: ValueCount[]; geometryCodes!: ValueCount[]; - usageCodes!: ValueCount[]; manCatLabelItemCodes!: ValueCount[]; + usageCodes!: ValueCount[]; + workgroupIds!: ValueCount[]; @Type(() => DateRangeDTO) createDate!: DateRangeDTO | null; diff --git a/libs/shared/src/lib/models/asset-search/asset-search-result.ts b/libs/shared/src/lib/models/asset-search/asset-search-result.ts index 2f6981be..0501ceeb 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-result.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-result.ts @@ -21,8 +21,9 @@ export interface AssetSearchStats { assetKindItemCodes: ValueCount[]; languageItemCodes: ValueCount[]; geometryCodes: ValueCount[]; - usageCodes: ValueCount[]; manCatLabelItemCodes: ValueCount[]; + usageCodes: ValueCount[]; + workgroupIds: ValueCount[]; createDate: DateRange | null; } diff --git a/libs/shared/src/lib/models/elastic-search-asset.ts b/libs/shared/src/lib/models/elastic-search-asset.ts index 639ff0d9..be654d1e 100644 --- a/libs/shared/src/lib/models/elastic-search-asset.ts +++ b/libs/shared/src/lib/models/elastic-search-asset.ts @@ -17,6 +17,7 @@ export interface ElasticSearchAsset { manCatLabelItemCodes: string[]; geometryCodes: GeometryCode[] | ['None']; studyLocations: ElasticPoint[]; + workgroupId: number; data: SerializedAssetEditDetail; } diff --git a/libs/shared/src/lib/models/index.ts b/libs/shared/src/lib/models/index.ts index bdea0064..994ea5da 100644 --- a/libs/shared/src/lib/models/index.ts +++ b/libs/shared/src/lib/models/index.ts @@ -17,7 +17,6 @@ export * from './study'; export * from './study-dto'; export * from './ReferenceData'; export * from './usage'; -export * from './user'; export * from './asset-search/asset-search-query'; export * from './asset-search/asset-search-query.dto'; diff --git a/libs/shared/src/lib/models/patch-asset.ts b/libs/shared/src/lib/models/patch-asset.ts index a0a5ad7f..a47cdb0a 100644 --- a/libs/shared/src/lib/models/patch-asset.ts +++ b/libs/shared/src/lib/models/patch-asset.ts @@ -36,5 +36,6 @@ export const PatchAsset = C.struct({ siblingAssetIds: C.array(C.number), newStudies: C.array(C.string), newStatusWorkItemCode: CT.optionFromNullable(C.string), + workgroupId: C.number, }); export type PatchAsset = C.TypeOf; diff --git a/libs/shared/src/lib/models/user.ts b/libs/shared/src/lib/models/user.ts deleted file mode 100644 index 4e6da192..00000000 --- a/libs/shared/src/lib/models/user.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { contramap } from 'fp-ts/Ord'; -import { Ord as ordString } from 'fp-ts/string'; -import * as C from 'io-ts/Codec'; -import * as D from 'io-ts/Decoder'; - -export enum UserRoleEnum { - admin = 'admin', - editor = 'editor', - masterEditor = 'master-editor', - viewer = 'viewer', -} - -const UserRoleDecoder = D.union( - D.literal(UserRoleEnum.admin), - D.literal(UserRoleEnum.editor), - D.literal(UserRoleEnum.masterEditor), - D.literal(UserRoleEnum.viewer) -); -export const UserRole = C.fromDecoder(UserRoleDecoder); -export type UserRole = D.TypeOf; - -export const isAdmin = (u: User) => u.role === UserRoleEnum.admin; -export const isMasterEditor = (u: User) => [UserRoleEnum.admin, UserRoleEnum.masterEditor].includes(u.role); -export const isEditor = (u: User) => - [UserRoleEnum.admin, UserRoleEnum.editor, UserRoleEnum.masterEditor].includes(u.role); - -export const User = C.struct({ - id: C.string, - email: C.string, - role: UserRole, - lang: C.string, -}); -export type User = D.TypeOf; -export const byEmail = contramap((u: User) => u.email)(ordString); - -export type UserWithoutId = Omit; - -export const Users = C.array(User); -export type Users = User[]; - -export const UserPost = D.struct({ - email: D.string, - role: UserRoleDecoder, - lang: D.string, - oidcId: D.string, - id: D.string, -}); -export type UserPost = D.TypeOf; - -export const UserPatch = D.struct({ - role: UserRoleDecoder, - lang: D.string, -}); -export type UserPatch = D.TypeOf; diff --git a/libs/shared/tsconfig.spec.json b/libs/shared/tsconfig.spec.json index 26ef046a..0068d77f 100644 --- a/libs/shared/tsconfig.spec.json +++ b/libs/shared/tsconfig.spec.json @@ -16,5 +16,6 @@ "src/**/*.test.jsx", "src/**/*.spec.jsx", "src/**/*.d.ts" - ] + ], + "exclude": ["v2"] } diff --git a/libs/shared/v2/.babelrc b/libs/shared/v2/.babelrc new file mode 100644 index 00000000..fd4cbcde --- /dev/null +++ b/libs/shared/v2/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nx/js/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/libs/shared/v2/README.md b/libs/shared/v2/README.md new file mode 100644 index 00000000..831e1f0a --- /dev/null +++ b/libs/shared/v2/README.md @@ -0,0 +1,11 @@ +# shared + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test shared` to execute the unit tests via [Jest](https://jestjs.io). + +## Running lint + +Run `nx lint shared` to execute the lint via [ESLint](https://eslint.org/). diff --git a/libs/shared/v2/eslint.config.js b/libs/shared/v2/eslint.config.js new file mode 100644 index 00000000..07e518f7 --- /dev/null +++ b/libs/shared/v2/eslint.config.js @@ -0,0 +1,3 @@ +const baseConfig = require('../../../eslint.config.js'); + +module.exports = [...baseConfig]; diff --git a/libs/shared/v2/jest.config.ts b/libs/shared/v2/jest.config.ts new file mode 100644 index 00000000..4c0d72f8 --- /dev/null +++ b/libs/shared/v2/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'shared/v2', + preset: '../../../jest.preset.js', + globals: {}, + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../../coverage/libs/shared/v2', +}; diff --git a/libs/shared/v2/package.json b/libs/shared/v2/package.json new file mode 100644 index 00000000..e21f5fa4 --- /dev/null +++ b/libs/shared/v2/package.json @@ -0,0 +1,4 @@ +{ + "name": "@asset-sg/shared/v2", + "version": "0.0.1" +} diff --git a/libs/shared/v2/project.json b/libs/shared/v2/project.json new file mode 100644 index 00000000..6a32a813 --- /dev/null +++ b/libs/shared/v2/project.json @@ -0,0 +1,33 @@ +{ + "name": "shared/v2", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/v2/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/shared/v2", + "main": "libs/shared/v2/src/index.ts", + "tsConfig": "libs/shared/v2/tsconfig.lib.json", + "assets": ["libs/shared/v2/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "eslintConfig": "libs/shared/v2/eslint.config.js" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/shared/v2/jest.config.ts" + } + } + } +} diff --git a/libs/shared/v2/src/index.ts b/libs/shared/v2/src/index.ts new file mode 100644 index 00000000..c9036f77 --- /dev/null +++ b/libs/shared/v2/src/index.ts @@ -0,0 +1,23 @@ +export * from './lib/models/base/model'; +export * from './lib/models/base/local-date'; + +export * from './lib/models/asset'; +export * from './lib/models/contact'; +export * from './lib/models/favorite'; +export * from './lib/models/study'; +export * from './lib/models/user'; +export * from './lib/models/workgroup'; + +export * from './lib/policies/base/policy'; +export * from './lib/policies/asset.policy'; +export * from './lib/policies/asset-edit.policy'; +export * from './lib/policies/contact.policy'; +export * from './lib/policies/workgroup.policy'; + +export * from './lib/schemas/base/schema'; +export * from './lib/schemas/asset.schema'; +export * from './lib/schemas/contact.schema'; +export * from './lib/schemas/user.schema'; +export * from './lib/schemas/workgroup.schema'; + +export * from './lib/utils/class-validator/is-nullable.decorator'; diff --git a/libs/shared/v2/src/lib/models/asset.ts b/libs/shared/v2/src/lib/models/asset.ts new file mode 100644 index 00000000..ce030a5f --- /dev/null +++ b/libs/shared/v2/src/lib/models/asset.ts @@ -0,0 +1,145 @@ +import { LocalDate } from './base/local-date'; +import { Data, Model } from './base/model'; +import { StudyType } from './study'; + +// `usageCode` will need to be determined in the frontend - it is no longer included here. +// See `makeUsageCode`. + +// `assetFormatCompositions` seems to be fully unused. +// The table on INT is empty, and there's no way to edit it. +// The field would theoretically be displayed in the search, but since it is empty, +// it's always skipped. + +export interface AssetInfo extends Model { + title: string; + originalTitle: string | null; + + kindCode: string; + formatCode: string; + identifiers: AssetIdentifier[]; + languageCodes: string[]; + contactAssignments: ContactAssignment[]; + manCatLabelCodes: string[]; + natRelCodes: string[]; + links: AssetLinks; + files: FileReference[]; + + createdAt: LocalDate; + receivedAt: LocalDate; + lastProcessedAt: Date; +} + +export interface AssetLinks { + parent: LinkedAsset | null; + children: LinkedAsset[]; + siblings: LinkedAsset[]; +} + +export interface AssetLinksData { + parent: AssetId | null; + siblings: AssetId[]; +} + +// Detailed data about an asset. +// These are the parts of `Asset` that were previously only part of `AssetEdit`. +// They are only visible on the asset edit page. +export interface AssetDetails { + sgsId: number | null; + municipality: string | null; + processor: string | null; + isNatRel: boolean; + infoGeol: InfoGeol; + usage: AssetUsages; + statuses: WorkStatus[]; + studies: AssetStudy[]; + workgroupId: number; +} + +export interface AssetUsages { + public: AssetUsage; + internal: AssetUsage; +} + +export type Asset = AssetInfo & AssetDetails; + +type NonDataKeys = 'processor' | 'identifiers' | 'studies' | 'statuses' | 'links' | 'lastProcessedAt' | 'files'; + +export interface AssetData extends Omit, NonDataKeys> { + links: AssetLinksData; + identifiers: (AssetIdentifier | AssetIdentifierData)[]; + statuses: (WorkStatus | WorkStatusData)[]; + studies: (AssetStudy | StudyData)[]; +} + +export interface InfoGeol { + main: string | null; + contact: string | null; + auxiliary: string | null; +} + +export interface AssetIdentifier extends Model { + name: string; + description: string; +} + +export type AssetIdentifierId = number; +export type AssetIdentifierData = Data; + +export type AssetId = number; + +export interface AssetUsage { + isAvailable: boolean; + statusCode: UsageStatusCode; + availableAt: LocalDate | null; +} + +export enum UsageStatusCode { + ToBeChecked = 'tobechecked', + UnderClarification = 'underclarification', + Approved = 'approved', +} + +export interface ContactAssignment { + contactId: number; + role: ContactAssignmentRole; +} + +export enum ContactAssignmentRole { + Author = 'author', + Initiator = 'initiator', + Supplier = 'supplier', +} + +export interface LinkedAsset { + id: AssetId; + title: string; +} + +export interface WorkStatus extends Model { + itemCode: WorkStatusCode; + createdAt: Date; +} + +export type WorkStatusCode = string; +export type WorkStatusData = Data; + +export interface FileReference { + id: number; + name: string; + size: number; +} + +export enum UsageCode { + Public = 'public', + Internal = 'internal', + UseOnRequest = 'useOnRequest', +} + +export interface AssetStudy extends Model { + geom: string; + type: StudyType; +} + +export type StudyData = Data; + +export type AssetStudyId = number; diff --git a/apps/server-asset-sg/src/utils/data/local-date.ts b/libs/shared/v2/src/lib/models/base/local-date.ts similarity index 100% rename from apps/server-asset-sg/src/utils/data/local-date.ts rename to libs/shared/v2/src/lib/models/base/local-date.ts diff --git a/apps/server-asset-sg/src/utils/data/model.ts b/libs/shared/v2/src/lib/models/base/model.ts similarity index 100% rename from apps/server-asset-sg/src/utils/data/model.ts rename to libs/shared/v2/src/lib/models/base/model.ts diff --git a/libs/shared/v2/src/lib/models/contact.ts b/libs/shared/v2/src/lib/models/contact.ts new file mode 100644 index 00000000..4eb5db50 --- /dev/null +++ b/libs/shared/v2/src/lib/models/contact.ts @@ -0,0 +1,16 @@ +import { Data, Model } from './base/model'; +export interface Contact extends Model { + name: string; + street: string | null; + houseNumber: string | null; + plz: string | null; + locality: string | null; + country: string | null; + telephone: string | null; + email: string | null; + website: string | null; + contactKindItemCode: string; +} + +export type ContactId = number; +export type ContactData = Data; diff --git a/apps/server-asset-sg/src/features/favorites/favorite.model.ts b/libs/shared/v2/src/lib/models/favorite.ts similarity index 55% rename from apps/server-asset-sg/src/features/favorites/favorite.model.ts rename to libs/shared/v2/src/lib/models/favorite.ts index e6bb0f63..f44c0484 100644 --- a/apps/server-asset-sg/src/features/favorites/favorite.model.ts +++ b/libs/shared/v2/src/lib/models/favorite.ts @@ -1,4 +1,4 @@ -import { UserId } from '@/features/users/user.model'; +import { UserId } from './user'; export interface Favorite { assetId: number; diff --git a/apps/server-asset-sg/src/features/studies/study.model.spec.ts b/libs/shared/v2/src/lib/models/study.spec.ts similarity index 93% rename from apps/server-asset-sg/src/features/studies/study.model.spec.ts rename to libs/shared/v2/src/lib/models/study.spec.ts index 9baf87ba..b74c0ba8 100644 --- a/apps/server-asset-sg/src/features/studies/study.model.spec.ts +++ b/libs/shared/v2/src/lib/models/study.spec.ts @@ -1,5 +1,5 @@ import { LV95X, LV95Y } from '@asset-sg/shared'; -import { serializeStudyAsCsv, Study } from './study.model'; +import { serializeStudyAsCsv, Study } from './study'; describe('serializeStudyAsCsv', () => { it('serializes a point study in CSV format', () => { diff --git a/apps/server-asset-sg/src/features/studies/study.model.ts b/libs/shared/v2/src/lib/models/study.ts similarity index 88% rename from apps/server-asset-sg/src/features/studies/study.model.ts rename to libs/shared/v2/src/lib/models/study.ts index 7601edc4..c8b8acb4 100644 --- a/apps/server-asset-sg/src/features/studies/study.model.ts +++ b/libs/shared/v2/src/lib/models/study.ts @@ -1,5 +1,5 @@ import { LV95 } from '@asset-sg/shared'; -import { AssetId } from '@/features/assets/asset.model'; +import { AssetId } from './asset'; export interface Study { id: StudyId; diff --git a/libs/shared/v2/src/lib/models/user.ts b/libs/shared/v2/src/lib/models/user.ts new file mode 100644 index 00000000..2213d554 --- /dev/null +++ b/libs/shared/v2/src/lib/models/user.ts @@ -0,0 +1,39 @@ +import { Data, Model } from './base/model'; +import { getRoleIndex, Role, WorkgroupId } from './workgroup'; + +export interface User extends Model { + email: string; + lang: string; + isAdmin: boolean; + + /** + * The user's roles, mapped by the id of the workgroup to which they apply. + */ + roles: Map; +} + +export type UserId = string; +export type UserData = Omit, 'email'>; + +const hasRole = (role: Role) => (user: User | null | undefined, workgroupId?: WorkgroupId) => { + if (user == null) { + return false; + } + if (user.isAdmin) { + return true; + } + const roleIndex = getRoleIndex(role); + if (workgroupId != null) { + const role = user.roles.get(workgroupId); + return role != null && getRoleIndex(role) >= roleIndex; + } + for (const userRole of user.roles.values()) { + if (getRoleIndex(userRole) >= roleIndex) { + return true; + } + } + return false; +}; + +export const isMasterEditor = hasRole(Role.MasterEditor); +export const isEditor = hasRole(Role.Editor); diff --git a/libs/shared/v2/src/lib/models/workgroup.ts b/libs/shared/v2/src/lib/models/workgroup.ts new file mode 100644 index 00000000..7f74d21d --- /dev/null +++ b/libs/shared/v2/src/lib/models/workgroup.ts @@ -0,0 +1,38 @@ +import { Role as PrismaRole } from '@prisma/client'; +import { Data, Model } from './base/model'; +import { UserId } from './user'; + +export interface Workgroup extends Model { + name: string; + users: Map; + disabledAt: Date | null; +} + +export interface UserOnWorkgroup { + email: string; + role: Role; +} + +export type WorkgroupId = number; +export type WorkgroupData = Omit, 'assets'>; +export type SimpleWorkgroup = Pick & { + /** + * The role of the current user within this workgroup. + * Note that admins are registered as {@link Role.MasterEditor} for every workgroup. + */ + role: Role; +}; + +export type Role = PrismaRole; +export const Role = PrismaRole; + +export const getRoleIndex = (role: Role): number => { + switch (role) { + case 'Viewer': + return 0; + case 'Editor': + return 1; + case 'MasterEditor': + return 2; + } +}; diff --git a/libs/shared/v2/src/lib/policies/asset-edit.policy.ts b/libs/shared/v2/src/lib/policies/asset-edit.policy.ts new file mode 100644 index 00000000..cbb9e712 --- /dev/null +++ b/libs/shared/v2/src/lib/policies/asset-edit.policy.ts @@ -0,0 +1,26 @@ +import { Role, WorkgroupId } from '../models/workgroup'; +import { Policy } from './base/policy'; + +type Asset = { workgroupId: WorkgroupId }; + +export class AssetEditPolicy extends Policy { + override canShow(value: Asset): boolean { + // A user can see all assets in all workgroups that they are assigned to. + return this.hasWorkgroup(value.workgroupId); + } + + override canCreate(): boolean { + // A user can create assets for workgroups for which they are an Editor. + return this.hasRole(Role.Editor); + } + + override canUpdate(value: Asset): boolean { + // A user can update assets for all workgroups for which they are an Editor. + return this.hasRole(Role.Editor, value.workgroupId); + } + + override canDelete(value: Asset): boolean { + // A user can delete assets for all workgroups for which they are an Editor. + return this.hasRole(Role.Editor, value.workgroupId); + } +} diff --git a/libs/shared/v2/src/lib/policies/asset.policy.ts b/libs/shared/v2/src/lib/policies/asset.policy.ts new file mode 100644 index 00000000..4fe7817b --- /dev/null +++ b/libs/shared/v2/src/lib/policies/asset.policy.ts @@ -0,0 +1,25 @@ +import { Asset } from '../models/asset'; +import { Role } from '../models/workgroup'; +import { Policy } from './base/policy'; + +export class AssetPolicy extends Policy { + canShow(value: Asset): boolean { + // A user can see all assets in all workgroups that they are assigned to. + return this.hasWorkgroup(value.workgroupId); + } + + override canCreate(): boolean { + // A user can create assets for workgroups for which they are an Editor. + return this.hasRole(Role.Editor); + } + + override canUpdate(value: Asset): boolean { + // A user can update assets for all workgroups for which they are an Editor. + return this.hasRole(Role.Editor, value.workgroupId); + } + + override canDelete(value: Asset): boolean { + // A user can delete assets for all workgroups for which they are an Editor. + return this.hasRole(Role.Editor, value.workgroupId); + } +} diff --git a/libs/shared/v2/src/lib/policies/base/policy.ts b/libs/shared/v2/src/lib/policies/base/policy.ts new file mode 100644 index 00000000..09051585 --- /dev/null +++ b/libs/shared/v2/src/lib/policies/base/policy.ts @@ -0,0 +1,56 @@ +import { User } from '../../models/user'; +import { getRoleIndex, Role, WorkgroupId } from '../../models/workgroup'; + +export abstract class Policy { + constructor(protected readonly user: User) {} + + protected hasWorkgroup(ids: WorkgroupId | Iterable): boolean { + ids = typeof ids === 'number' ? [ids] : ids; + for (const id of ids) { + if (this.user.roles.has(id)) { + return true; + } + } + return false; + } + + protected withWorkgroupRole(ids: WorkgroupId | Iterable, action: (role: Role) => boolean): boolean { + ids = typeof ids === 'number' ? [ids] : ids; + for (const id of ids) { + const role = this.user.roles.get(id); + if (role != null && action(role)) { + return true; + } + } + return false; + } + + hasRole(role: Role, ids?: WorkgroupId | Iterable): boolean { + const roleIndex = getRoleIndex(role); + if (ids == null) { + for (const role of this.user.roles.values()) { + if (getRoleIndex(role) >= roleIndex) { + return true; + } + } + return false; + } + return this.withWorkgroupRole(ids, (role) => getRoleIndex(role) >= roleIndex); + } + + canDoEverything(): boolean { + return this.user.isAdmin; + } + + abstract canShow(value: T): boolean; + + abstract canCreate(): boolean; + + canUpdate(_value: T): boolean { + return this.canCreate(); + } + + canDelete(_value: T): boolean { + return this.canCreate(); + } +} diff --git a/libs/shared/v2/src/lib/policies/contact.policy.ts b/libs/shared/v2/src/lib/policies/contact.policy.ts new file mode 100644 index 00000000..bbccb647 --- /dev/null +++ b/libs/shared/v2/src/lib/policies/contact.policy.ts @@ -0,0 +1,14 @@ +import { Contact } from '../models/contact'; +import { Role } from '../models/workgroup'; +import { Policy } from './base/policy'; + +export class ContactPolicy extends Policy { + canShow(_value: Contact): boolean { + return this.hasRole(Role.Editor); + } + + canCreate(): boolean { + // A user can create assets for workgroups for which they are an Editor. + return this.hasRole(Role.Editor); + } +} diff --git a/libs/shared/v2/src/lib/policies/workgroup.policy.ts b/libs/shared/v2/src/lib/policies/workgroup.policy.ts new file mode 100644 index 00000000..311723a5 --- /dev/null +++ b/libs/shared/v2/src/lib/policies/workgroup.policy.ts @@ -0,0 +1,14 @@ +import { Workgroup } from '../models/workgroup'; +import { Policy } from './base/policy'; + +export class WorkgroupPolicy extends Policy { + canShow(value: Workgroup): boolean { + // A user can see every workgroup assigned to them. + return this.hasWorkgroup(value.id); + } + + canCreate(): boolean { + // Only admins can create workgroups. + return false; + } +} diff --git a/libs/shared/v2/src/lib/schemas/asset.schema.ts b/libs/shared/v2/src/lib/schemas/asset.schema.ts new file mode 100644 index 00000000..68b055fb --- /dev/null +++ b/libs/shared/v2/src/lib/schemas/asset.schema.ts @@ -0,0 +1,206 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsDate, + IsEnum, + IsInt, + IsNumber, + IsObject, + IsString, + ValidateNested, +} from 'class-validator'; +import { + AssetData, + AssetIdentifierData, + AssetLinksData, + AssetUsage, + AssetUsages, + ContactAssignment, + ContactAssignmentRole, + InfoGeol, + StudyData, + UsageStatusCode, + WorkStatusData, +} from '../models/asset'; +import { LocalDate } from '../models/base/local-date'; +import { StudyType } from '../models/study'; +import { IsNullable, messageNullableInt, messageNullableString } from '../utils/class-validator/is-nullable.decorator'; +import { Schema } from './base/schema'; + +export class AssetUsageSchema extends Schema implements AssetUsage { + @IsBoolean() + isAvailable!: boolean; + + @IsEnum(UsageStatusCode) + statusCode!: UsageStatusCode; + + @IsNullable() + @ValidateNested() + @Type(() => String) + @Transform(({ value }) => LocalDate.parse(value)) + availableAt!: LocalDate | null; +} + +export class AssetUsagesSchema extends Schema implements AssetUsages { + @IsObject() + @ValidateNested() + @Type(() => AssetUsageSchema) + public!: AssetUsageSchema; + + @IsObject() + @ValidateNested() + @Type(() => AssetUsageSchema) + internal!: AssetUsageSchema; +} + +export class InfoGeolSchema extends Schema implements InfoGeol { + @IsString({ message: messageNullableString }) + @IsNullable() + main!: string | null; + + @IsString({ message: messageNullableString }) + @IsNullable() + contact!: string | null; + + @IsString({ message: messageNullableString }) + @IsNullable() + auxiliary!: string | null; +} + +export class ContactAssignmentSchema extends Schema implements ContactAssignment { + @IsInt() + contactId!: number; + + @IsEnum(ContactAssignmentRole) + role!: ContactAssignmentRole; +} + +export class StudyDataSchema extends Schema implements StudyData { + @IsInt({ message: messageNullableInt }) + @IsNullable() + id?: number | undefined; + + @IsString() + geom!: string; + + @IsEnum(StudyType) + type!: StudyType; +} + +export class WorkStatusSchema extends Schema implements WorkStatusData { + @IsInt({ message: messageNullableInt }) + @IsNullable() + id?: number | undefined; + + @IsDate() + @Type(() => Date) + createdAt!: Date; + + @IsString() + itemCode!: string; +} + +export class AssetIdentifierSchema extends Schema implements AssetIdentifierData { + @IsInt({ message: messageNullableInt }) + @IsNullable() + id?: number | undefined; + + @IsString() + name!: string; + + @IsString() + description!: string; +} + +export class AssetLinksDataSchema extends Schema implements AssetLinksData { + @IsInt({ message: messageNullableInt }) + @IsNullable() + parent!: number | null; + + @IsInt({ each: true }) + siblings!: number[]; +} + +export class AssetDataSchema extends Schema implements AssetData { + @IsObject() + @ValidateNested() + @Type(() => AssetLinksDataSchema) + links!: AssetLinksDataSchema; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetIdentifierSchema) + identifiers!: AssetIdentifierSchema[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WorkStatusSchema) + statuses!: WorkStatusSchema[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StudyDataSchema) + studies!: StudyDataSchema[]; + + @IsString() + title!: string; + + @IsString({ message: messageNullableString }) + @IsNullable() + originalTitle!: string | null; + + @IsString() + kindCode!: string; + + @IsString() + formatCode!: string; + + @IsString({ each: true }) + languageCodes!: string[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContactAssignmentSchema) + contactAssignments!: ContactAssignmentSchema[]; + + @IsString({ each: true }) + manCatLabelCodes!: string[]; + + @IsString({ each: true }) + natRelCodes!: string[]; + + @ValidateNested() + @Type(() => String) + @Transform(({ value }) => LocalDate.tryParse(value)) + createdAt!: LocalDate; + + @ValidateNested() + @Type(() => String) + @Transform(({ value }) => LocalDate.tryParse(value)) + receivedAt!: LocalDate; + + @IsInt({ message: messageNullableInt }) + @IsNullable() + sgsId!: number | null; + + @IsString({ message: messageNullableString }) + @IsNullable() + municipality!: string | null; + + @IsBoolean() + isNatRel!: boolean; + + @IsObject() + @ValidateNested() + @Type(() => InfoGeolSchema) + infoGeol!: InfoGeolSchema; + + @IsObject() + @ValidateNested() + @Type(() => AssetUsagesSchema) + usage!: AssetUsagesSchema; + + @IsNumber() + workgroupId!: number; +} diff --git a/libs/shared/v2/src/lib/schemas/base/schema.ts b/libs/shared/v2/src/lib/schemas/base/schema.ts new file mode 100644 index 00000000..ccf5c4ed --- /dev/null +++ b/libs/shared/v2/src/lib/schemas/base/schema.ts @@ -0,0 +1,112 @@ +import { instanceToPlain, Transform } from 'class-transformer'; +import { validate } from 'class-validator'; +import { Class } from 'type-fest'; + +/** + * A _schema_ is a class that defines how a specific interface is converted from and to JSON. + * This is their base type. + * + * Schemas support validation using the `class-validator` library and transform values using the `class-transform` library. + * + * # Converting plain values + * A _plain value_ is an object as it is represented in JSON. + * In other words, it's what you get when converting a schema instance to JSON and then parsing it using {@link JSON.parse}. + * To convert a plain value into an instance of schema, use {@link plainToInstance}: + * ```ts + * const plainValue = JSON.parse(...); + * const instanceValue = plainToInstance(MySchema, plainValue); + * ``` + * Whenever you're using a schema, this conversion has to be done right after parsing a value from JSON. + * + * > In NestJS, the conversion and validation of values is handled automatically, + * > as long as you define a `ValidationPipe` and use the schemas in the parameters of your API routes. + * + * # Converting instance values + * Conversion of schema instances is handled automatically by {@link JSON.stringify}. + * If you ever need to the plain value as a JSON object, use {@link instanceToPlain}: + * ```ts + * const instanceValue = ...; + * const plainValue = instanceToPlain(instanceValue); + * ``` + * + * # Converting non-instance values to instance values + * Often, you will have values that conform to a schema's interface, but are not instances of the schema itself. + * For these values, conversion to plain values will not happen automatically. + * To convert them to schema instances, use {@link convert}: + * ```ts + * const objectValue = ... + * const instanceValue = convert(MySchema, objectValue); + * ``` + * > A common reason for having to deal with non-instance values is due to loading values from a database + * > or other external service. + * + * # Validating instance values + * After converting a plain value to an instance value, it is often a good idea to validate it, + * ensuring that it fits all criteria of the schema. + * This can be done using the {@link validate} function: + * ```ts + * const instanceValue = ...; + * const errors = await validate(instanceValue); + * ``` + * If you simply want to throw an exception when validation fails, use {@link validateOrReject}: + * ```ts + * await validateOrReject(instanceValue); + * ``` + * > Note that it's perfectly okay to not validate a newly converted instance + * > in case you trust its source to always provide valid data. + * > For example, validating API responses will most likely not make much sense. + */ +export class Schema { + /** + * Converts the schema instance to a plain value that can be converted to JSON using {@link JSON.stringify}. + */ + toJSON(): object { + return instanceToPlain(this); + } +} + +/** + * Converts an object into a {@link Schema} instance. + * + * @param schema The schema class. + * @param value The value to convert. + */ +export function convert(schema: Class, value: T): S; + +/** + * Converts an array of object into an array of {@link Schema} instances. + * + * @param schema The schema class. + * @param value The values to convert. + */ +export function convert(schema: Class, value: T[]): S[]; + +export function convert(schema: Class, value: T | T[]): S | S[] { + return Array.isArray(value) ? value.map((it) => convertSingle(schema, it)) : convertSingle(schema, value); +} + +const convertSingle = (schema: Class, value: T): S => { + const instance: S = Object.create(schema.prototype); + for (const [key, keyValue] of Object.entries(value) as Array<[keyof T, S[keyof T]]>) { + instance[key] = keyValue; + } + return instance; +}; + +export const TransformMap = (): PropertyDecorator => { + const transformToClass = Transform( + ({ value }) => { + const map = new Map(); + for (const [key, keyValue] of value) { + map.set(key, keyValue); + } + return map; + }, + { toClassOnly: true } + ); + const transformToPlain = Transform(({ value }) => [...value.entries()], { toPlainOnly: true }); + return (target, propertyKey) => { + transformToClass(target, propertyKey); + transformToPlain(target, propertyKey); + }; +}; diff --git a/apps/server-asset-sg/src/features/contacts/contact.model.ts b/libs/shared/v2/src/lib/schemas/contact.schema.ts similarity index 54% rename from apps/server-asset-sg/src/features/contacts/contact.model.ts rename to libs/shared/v2/src/lib/schemas/contact.schema.ts index e4fbb210..353ab06e 100644 --- a/apps/server-asset-sg/src/features/contacts/contact.model.ts +++ b/libs/shared/v2/src/lib/schemas/contact.schema.ts @@ -1,24 +1,8 @@ import { IsOptional, IsString } from 'class-validator'; +import { ContactData } from '../models/contact'; +import { Schema } from './base/schema'; -import { Data, Model } from '@/utils/data/model'; - -export interface Contact extends Model { - name: string; - street: string | null; - houseNumber: string | null; - plz: string | null; - locality: string | null; - country: string | null; - telephone: string | null; - email: string | null; - website: string | null; - contactKindItemCode: string; -} - -export type ContactId = number; -export type ContactData = Data; - -export class ContactDataBoundary implements ContactData { +export class ContactDataSchema extends Schema implements ContactData { @IsString() name!: string; diff --git a/libs/shared/v2/src/lib/schemas/user.schema.ts b/libs/shared/v2/src/lib/schemas/user.schema.ts new file mode 100644 index 00000000..0838aed5 --- /dev/null +++ b/libs/shared/v2/src/lib/schemas/user.schema.ts @@ -0,0 +1,25 @@ +import { Role } from '@prisma/client'; +import { IsBoolean, IsEnum, IsString } from 'class-validator'; +import { User, UserData, UserId } from '../models/user'; +import { WorkgroupId } from '../models/workgroup'; +import { Schema, TransformMap } from './base/schema'; + +export class UserDataSchema extends Schema implements UserData { + @IsString() + lang!: string; + + @IsBoolean() + isAdmin!: boolean; + + @TransformMap() + @IsEnum(Role, { each: true }) + roles!: Map; +} + +export class UserSchema extends UserDataSchema implements User { + @IsString() + id!: UserId; + + @IsString() + email!: string; +} diff --git a/libs/shared/v2/src/lib/schemas/workgroup.schema.ts b/libs/shared/v2/src/lib/schemas/workgroup.schema.ts new file mode 100644 index 00000000..5724318f --- /dev/null +++ b/libs/shared/v2/src/lib/schemas/workgroup.schema.ts @@ -0,0 +1,35 @@ +import { Role } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsDate, IsEnum, IsNumber, IsString, ValidateNested } from 'class-validator'; +import { UserId } from '../models/user'; +import { UserOnWorkgroup, Workgroup, WorkgroupData, WorkgroupId } from '../models/workgroup'; +import { IsNullable } from '../utils/class-validator/is-nullable.decorator'; +import { Schema, TransformMap } from './base/schema'; + +export class WorkgroupDataSchema extends Schema implements WorkgroupData { + @IsString() + name!: string; + + @TransformMap() + @ValidateNested({ each: true }) + @Type(() => UserOnWorkgroupSchema) + users!: Map; + + @IsDate() + @IsNullable() + @Type(() => Date) + disabledAt!: Date | null; +} + +export class WorkgroupSchema extends WorkgroupDataSchema implements Workgroup { + @IsNumber() + id!: WorkgroupId; +} + +export class UserOnWorkgroupSchema implements UserOnWorkgroup { + @IsString() + email!: string; + + @IsEnum(Role) + role!: Role; +} diff --git a/apps/server-asset-sg/src/core/decorators/is-nullable.decorator.ts b/libs/shared/v2/src/lib/utils/class-validator/is-nullable.decorator.ts similarity index 100% rename from apps/server-asset-sg/src/core/decorators/is-nullable.decorator.ts rename to libs/shared/v2/src/lib/utils/class-validator/is-nullable.decorator.ts diff --git a/libs/shared/v2/tsconfig.json b/libs/shared/v2/tsconfig.json new file mode 100644 index 00000000..b4fc3fa0 --- /dev/null +++ b/libs/shared/v2/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true + } +} diff --git a/libs/shared/v2/tsconfig.lib.json b/libs/shared/v2/tsconfig.lib.json new file mode 100644 index 00000000..4a05ef7f --- /dev/null +++ b/libs/shared/v2/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts"] +} diff --git a/libs/shared/v2/tsconfig.spec.json b/libs/shared/v2/tsconfig.spec.json new file mode 100644 index 00000000..25b7af8f --- /dev/null +++ b/libs/shared/v2/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 95c90370..a7d6d6b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,7 @@ "solid-js": "^1.6.9", "tsafe": "^1.7.1", "tslib": "^2.3.0", - "type-fest": "^3.5.3", + "type-fest": "^4.21.0", "url-join": "^5.0.0", "vite-plugin-solid": "^2.5.0", "zone.js": "0.14.6" @@ -103,7 +103,7 @@ "@types/jest": "^29.4.4", "@types/jsonwebtoken": "^9.0.1", "@types/jwk-to-pem": "^2.0.3", - "@types/multer": "^1.4.7", + "@types/multer": "^1.4.11", "@types/node": "^18.16.9", "@types/proj4": "^2.5.5", "@types/validator": "^13.7.10", @@ -10835,6 +10835,7 @@ "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*" } @@ -28591,11 +28592,12 @@ } }, "node_modules/type-fest": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", - "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz", + "integrity": "sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=14.16" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index a5db4836..3a28c6cb 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "npx nx run-many -t build -p server-asset-sg client-asset-sg --configuration=production", "build:server": "npx nx build server-asset-sg --configuration=production", "build:client": "npx nx build client-asset-sg --configuration=production", - "prisma": "npx dotenv -e apps/server-asset-sg/.env -e apps/server-asset-sg/.env.local -- npx prisma", + "prisma": "dotenv -e apps/server-asset-sg/.env -e apps/server-asset-sg/.env.local -- npx prisma", "test": "npx nx run-many --target=test --all", "format": "npm run prettier --write . && npm run lint:fix", "lint": "npx prettier --check . && npx nx run-many --target=lint --all", @@ -80,7 +80,7 @@ "solid-js": "^1.6.9", "tsafe": "^1.7.1", "tslib": "^2.3.0", - "type-fest": "^3.5.3", + "type-fest": "^4.21.0", "url-join": "^5.0.0", "vite-plugin-solid": "^2.5.0", "zone.js": "0.14.6" @@ -114,7 +114,7 @@ "@types/jest": "^29.4.4", "@types/jsonwebtoken": "^9.0.1", "@types/jwk-to-pem": "^2.0.3", - "@types/multer": "^1.4.7", + "@types/multer": "^1.4.11", "@types/node": "^18.16.9", "@types/proj4": "^2.5.5", "@types/validator": "^13.7.10", diff --git a/test/setup-db.ts b/test/setup-db.ts index 96280ebc..1b8412d4 100644 --- a/test/setup-db.ts +++ b/test/setup-db.ts @@ -1,11 +1,11 @@ import { PrismaClient } from '@prisma/client'; -import { assetKindItems } from './data/asset-kind-item'; -import { statusAssetUseItems } from './data/status-asset-use-item'; import { assetFormatItems } from './data/asset-format-item'; +import { assetKindItems } from './data/asset-kind-item'; +import { contactKindItems } from './data/contact-kind-item'; import { languageItems } from './data/language-items'; -import { statusWorkItems } from './data/status-work-item'; import { manCatLabelItems } from './data/man-cat-label-item'; -import { contactKindItems } from './data/contact-kind-item'; +import { statusAssetUseItems } from './data/status-asset-use-item'; +import { statusWorkItems } from './data/status-work-item'; const clearDB = async (prisma: PrismaClient, dbName: string): Promise => { const tables = await prisma.$queryRawUnsafe(` @@ -26,7 +26,6 @@ const clearDB = async (prisma: PrismaClient, dbName: string): Promise => { }; export const setupDB = async (prisma: PrismaClient): Promise => { - await clearDB(prisma, 'auth'); await clearDB(prisma, 'public'); await prisma.statusAssetUseItem.createMany({ data: statusAssetUseItems, skipDuplicates: true }); @@ -36,6 +35,7 @@ export const setupDB = async (prisma: PrismaClient): Promise => { await prisma.statusWorkItem.createMany({ data: statusWorkItems, skipDuplicates: true }); await prisma.manCatLabelItem.createMany({ data: manCatLabelItems, skipDuplicates: true }); await prisma.contactKindItem.createMany({ data: contactKindItems, skipDuplicates: true }); + await setupDefaultWorkgroup(prisma); }; export const clearPrismaAssets = async (prisma: PrismaClient): Promise => { @@ -49,4 +49,17 @@ export const clearPrismaAssets = async (prisma: PrismaClient): Promise => await prisma.asset.deleteMany(); await prisma.internalUse.deleteMany(); await prisma.publicUse.deleteMany(); + await prisma.workgroupsOnUsers.deleteMany(); + await prisma.workgroup.deleteMany({ where: { id: { not: 1 } } }); +}; + +export const setupDefaultWorkgroup = async (prisma: PrismaClient): Promise => { + await prisma.workgroup.create({ + data: { + id: 1, + created_at: new Date(), + disabled_at: new Date(), + name: 'Default', + }, + }); }; diff --git a/tsconfig.base.json b/tsconfig.base.json index c51d28a2..e0297a78 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,7 @@ "@asset-sg/favourite": ["libs/favourite/src/index.ts"], "@asset-sg/profile": ["libs/profile/src/index.ts"], "@asset-sg/shared": ["libs/shared/src/index.ts"], + "@asset-sg/shared/v2": ["libs/shared/v2/src/index.ts"], "ngx-kobalte": ["libs/ngx-kobalte/src/index.ts"] } }, diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 00000000..23f53042 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "dist/out-tsc", + "module": "commonjs", + "types": ["jest"], + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["**/jest.config.ts", "**/jest.setup.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] +}