diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index db8e4f6c..b64c0fda 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -8,13 +8,21 @@ on: - "!main" env: - NODE_VERSION: "20.x" + NODE_VERSION: "22.x" DB_USERNAME: postgres DB_PASSWORD: postgres DB_DATABASE: postgres DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres?schema=public jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Review Dependencies + uses: actions/dependency-review-action@v4 + install: runs-on: ubuntu-latest steps: @@ -31,40 +39,52 @@ jobs: uses: actions/cache@v4 with: path: ${{ steps.npm-cache-dir.outputs.dir }} - key: "${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}" + key: "${{ runner.os }}-npm-${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}" restore-keys: | ${{ runner.os }}-npm- - name: Cache node modules uses: actions/cache@v4 with: path: ./node_modules - key: "${{ runner.os }}-node_modules-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/schema.prisma') }}" + key: "${{ runner.os }}-node_modules-${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/schema.prisma') }}" restore-keys: | ${{ runner.os }}-node_modules- - name: Cache e2e node modules uses: actions/cache@v4 with: path: ./e2e/node_modules - key: "${{ runner.os }}-node_modules_e2e-${{ hashFiles('./e2e/package-lock.json') }}" + key: "${{ runner.os }}-node_modules_e2e-${{ env.NODE_VERSION }}-${{ hashFiles('./e2e/package-lock.json') }}" restore-keys: | ${{ runner.os }}-node_modules_e2e- - name: Install node dependencies - run: npm ci + run: npm install - name: Generate prisma types run: npm run prisma -- generate - name: Install e2e node dependencies run: cd e2e && npm ci - cypress: + test: runs-on: ubuntu-latest - needs: - - install - strategy: - # https://github.com/cypress-io/github-action/issues/48 - fail-fast: false - matrix: - # Use 2 parallel instances - containers: [1, 2] + needs: install + services: + db: + image: postgis/postgis + ports: + - "5432:5432" + env: + POSTGRES_USER: ${{ env.DB_USERNAME }} + POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} + POSTGRES_DB: ${{ env.DB_DATABASE }} + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.1 + ports: + - "9200:9200" + env: + ES_JAVA_OPTS: -Xms512m -Xmx512m + xpack.security.enabled: false + discovery.type: single-node + cluster.routing.allocation.disk.threshold_enabled: false steps: - name: Checkout repository uses: actions/checkout@v4 @@ -76,44 +96,121 @@ jobs: uses: actions/cache/restore@v4 with: path: ./node_modules - key: "${{ runner.os }}-node_modules-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/schema.prisma') }}" - - name: Start services - env: - DB_USER: ${{ env.DB_USERNAME }} - DB_PASSWORD: ${{ env.DB_PASSWORD }} - run: | - cd development - chmod +x ./init/elasticsearch/init.sh - sed -i 's/- \.\/volumes\/elasticsearch\/data:\/usr\/share\/elasticsearch\/data//g' ./docker-compose.yaml - docker compose up -d db oidc elasticsearch - sleep 60 + key: "${{ runner.os }}-node_modules-${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/schema.prisma') }}" - name: Migrate database run: npm run prisma -- migrate deploy - - name: Restore cached e2e node modules + - name: Run tests + run: npm run test + + lint: + runs-on: ubuntu-latest + needs: install + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Restore cached node modules uses: actions/cache/restore@v4 with: - path: ./e2e/node_modules - key: "${{ runner.os }}-node_modules_e2e-${{ hashFiles('./e2e/package-lock.json') }}" - - name: Cypress run - uses: cypress-io/github-action@v6 + path: ./node_modules + key: "${{ runner.os }}-node_modules-${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/schema.prisma') }}" + - name: Run lint + run: npm run lint + - name: Run prettier + run: npx prettier --check . + + # It would be cleaner and probably more performant to replace this build step + # with either a non-emitting build or a simple type check. + # We only have `build` available for now, + # since the project is currently split across a multitude of small packages, + # all of which have to specify their own commands. + # (Daniel von Atzigen, 2024-04-12) + build: + runs-on: ubuntu-latest + needs: + - test + - lint + - dependency-review + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 with: - command: | - npx cypress run \ - --browser edge \ - --record \ - --parallel \ - --key ${{ secrets.CYPRESS_RECORD_KEY }} \ - --ci-build-id ${{ github.repository }}-${{ github.head_ref || github.ref_name }}-${{ github.sha }} - build: npm run build - start: npm start - wait-on: "http://localhost:4200" - wait-on-timeout: 120 - working-directory: ./e2e - env: - VITE_APP_VERSION: 0.0.99+dev - TZ: Europe/Zurich - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Stop services - run: | - cd development - docker compose down + node-version: ${{ env.NODE_VERSION }} + - name: Restore cached node modules + uses: actions/cache/restore@v4 + with: + path: ./node_modules + key: "${{ runner.os }}-node_modules-${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/schema.prisma') }}" + - name: Reset nx + run: npx nx reset + - name: Run build + run: npm run build +# cypress: +# runs-on: ubuntu-latest +# needs: +# - test +# - lint +# - dependency-review +# strategy: +# # https://github.com/cypress-io/github-action/issues/48 +# fail-fast: false +# matrix: +# # Use 2 parallel instances +# containers: [1, 2] +# steps: +# - name: Checkout repository +# uses: actions/checkout@v4 +# - name: Setup node +# uses: actions/setup-node@v4 +# with: +# node-version: ${{ env.NODE_VERSION }} +# - name: Restore cached node modules +# uses: actions/cache/restore@v4 +# with: +# path: ./node_modules +# key: "${{ runner.os }}-node_modules-${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/schema.prisma') }}" +# - name: Start services +# env: +# DB_USER: ${{ env.DB_USERNAME }} +# DB_PASSWORD: ${{ env.DB_PASSWORD }} +# run: | +# cd development +# chmod +x ./init/elasticsearch/init.sh +# sed -i 's/- \.\/volumes\/elasticsearch\/data:\/usr\/share\/elasticsearch\/data//g' ./docker-compose.yaml +# docker compose up -d db oidc elasticsearch +# sleep 60 +# - name: Migrate database +# run: npm run prisma -- migrate deploy +# - name: Restore cached e2e node modules +# uses: actions/cache/restore@v4 +# with: +# path: ./e2e/node_modules +# key: "${{ runner.os }}-node_modules_e2e-${{ env.NODE_VERSION }}-${{ hashFiles('./e2e/package-lock.json') }}" +# - name: Cypress run +# uses: cypress-io/github-action@v6 +# with: +# command: | +# npx cypress run \ +# --browser edge \ +# --record \ +# --parallel \ +# --key ${{ secrets.CYPRESS_RECORD_KEY }} \ +# --ci-build-id ${{ github.repository }}-${{ github.head_ref || github.ref_name }}-${{ github.sha }} +# build: npm run build +# start: npm start +# wait-on: "http://localhost:4200" +# wait-on-timeout: 120 +# working-directory: ./e2e +# env: +# VITE_APP_VERSION: 0.0.99+dev +# TZ: Europe/Zurich +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# - name: Stop services +# run: | +# cd development +# docker compose down 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 new file mode 100644 index 00000000..01b8b669 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## [Unreleased] + +### Added + +- Suchfilter zeigen Anzahl Ergebnisse und sind inital verfügbar +- Mehrsprachigkeit von Assets +- Versionsnummer der Applikation wird angezeigt +- 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 + +- Error Handling diff --git a/README.md b/README.md index a53f8dc3..53ead5b5 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ npm run prisma -- migrate deploy # Import example data: cd development -docker compose exec db sh -c 'psql --dbname=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB} -f /dump.sql' +docker compose exec db sh -c 'psql --dbname=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB} -v ON_ERROR_STOP=1 -f /dump.sql' ``` > You will need to manually sync the data to Elasticsearch via the admin panel in the web UI. diff --git a/apps/client-asset-sg/docker/Dockerfile b/apps/client-asset-sg/docker/Dockerfile index 615c37cc..84e9a7a4 100644 --- a/apps/client-asset-sg/docker/Dockerfile +++ b/apps/client-asset-sg/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine as app-builder +FROM node:22-alpine as app-builder ARG APP_VERSION ENV APP_VERSION=${APP_VERSION} 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/docker/Dockerfile b/apps/server-asset-sg/docker/Dockerfile index 1e68d0f8..b39cbfe2 100644 --- a/apps/server-asset-sg/docker/Dockerfile +++ b/apps/server-asset-sg/docker/Dockerfile @@ -1,6 +1,6 @@ ARG APP_VERSION -FROM node:20-alpine as api-builder +FROM node:22-alpine as api-builder ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV=development @@ -12,7 +12,7 @@ RUN npm install && ./node_modules/.bin/prisma generate RUN npx nx build server-asset-sg --configuration=production # final image build -FROM node:20-alpine +FROM node:22-alpine ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV=production 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/e2e/cypress/e2e/create-elastic-index.feature b/e2e/cypress/e2e/create-elastic-index.feature index 082738ed..493e7e57 100644 --- a/e2e/cypress/e2e/create-elastic-index.feature +++ b/e2e/cypress/e2e/create-elastic-index.feature @@ -2,7 +2,7 @@ Feature: Elastic index Background: Given The user is logged in - Given User has admin permissions + And User has admin permissions Scenario: User creates elastic index When A user clicks administration menu button 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..cdd3035e --- /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..14e17711 --- /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 }), + isDisabled: new FormControl(false, { nonNullable: true }), + 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, + isDisabled: workgroup.disabledAt != null, + 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.isDisabled.value ? this.workgroup?.disabledAt ?? new Date() : 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 3d0c4e9f..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

    @@ -54,9 +58,15 @@

    search.author

    - {{ author.name }} ({{ author.count }}) +
    + {{ author.name }} +
    +
    ({{ author.count }})
    @@ -79,7 +89,7 @@

    search.documentDate

    - +