From afd466655c84d0119224df996346356b572dfb8e Mon Sep 17 00:00:00 2001 From: Daniel von Atzigen Date: Mon, 30 Sep 2024 14:11:04 +0200 Subject: [PATCH] Add legal docs Create legal_docs migration Handle legal files in api Display legal docs in asset detail slider Add legal files to asset form Reset server/.env.local Update README Extract asset viewer files component Merge duplicate postgres asset file mapping Fix code quality issues Replace string match with Regexp.exec Use character class over explicit group in regex Remove unused imports Add missing `readonly` qualifiers Fix name of relation from `file` to `legal_doc_item` table Fix typo in README Translate `legalDocItemCode` in selector wip: Split file editor by type Add unit tests for `FileRepo.determineUniqueFilename` Update CHANGELOG.md --- .gitignore | 3 + CHANGELOG.md | 5 + README.md | 68 ++++--- apps/client-asset-sg/src/app/i18n/de.ts | 16 +- apps/client-asset-sg/src/app/i18n/en.ts | 14 +- apps/client-asset-sg/src/app/i18n/fr.ts | 14 +- apps/client-asset-sg/src/app/i18n/it.ts | 16 +- apps/client-asset-sg/src/app/i18n/rm.ts | 18 +- apps/server-asset-sg/.gitignore | 1 + apps/server-asset-sg/src/app.module.ts | 2 + .../features/asset-edit/asset-edit.fake.ts | 1 + .../features/asset-edit/asset-edit.repo.ts | 28 +-- .../features/asset-edit/asset-edit.service.ts | 109 +--------- .../src/features/assets/prisma-asset.ts | 12 +- .../src/features/files/file.repo.spec.ts | 66 ++++++ .../src/features/files/file.repo.ts | 159 +++++++++++++++ .../src/features/files/files.controller.ts | 96 ++++++--- .../src/features/ocr/ocr.controller.ts | 29 ++- .../src/models/AssetDetailFromPostgres.ts | 37 ++-- .../src/models/asset-edit-detail.ts | 14 +- .../src/utils/file/get-file.ts | 17 +- development/.env | 2 +- .../src/lib/asset-editor.module.ts | 16 +- .../asset-editor-files.component.html | 80 ++++++++ .../asset-editor-files.component.scss | 61 ++++++ .../asset-editor-files.component.ts | 76 +++++++ .../lib/components/asset-editor-form-group.ts | 43 +++- .../asset-editor-tab-contacts.component.html | 6 +- .../asset-editor-tab-contacts.component.ts | 30 +-- .../asset-editor-tab-files.component.html | 4 + .../asset-editor-tab-files.component.scss | 11 + .../asset-editor-tab-files.component.ts | 30 +++ .../asset-editor-tab-general.component.html | 110 ++-------- .../asset-editor-tab-general.component.scss | 19 -- .../asset-editor-tab-general.component.ts | 189 +++++++----------- ...asset-editor-tab-geometries.component.html | 2 +- .../asset-editor-tab-geometries.component.ts | 13 +- .../asset-editor-tab-page.component.html | 10 +- .../asset-editor-tab-page.component.ts | 184 ++++++++++------- .../src/lib/services/asset-editor.service.ts | 30 +-- .../src/lib/state/asset-editor.actions.ts | 3 +- .../src/lib/asset-viewer.module.ts | 2 + .../asset-search-detail.component.html | 38 +--- .../asset-search-detail.component.scss | 22 +- .../asset-search-detail.component.ts | 73 +++---- .../asset-viewer-files.component.html | 26 +++ .../asset-viewer-files.component.scss | 32 +++ .../asset-viewer-files.component.ts | 93 +++++++++ .../asset-search/asset-search.selector.ts | 45 ++--- .../src/lib/io-ts-types/Decoder/bigint.ts | 7 + .../core/src/lib/io-ts-types/Decoder/index.ts | 2 +- .../migration.sql | 37 ++++ libs/persistence/prisma/schema.prisma | 41 ++-- libs/shared/src/lib/models/asset-detail.ts | 18 +- libs/shared/src/lib/models/asset-edit.ts | 17 +- libs/shared/src/lib/models/patch-asset.ts | 3 +- 56 files changed, 1361 insertions(+), 739 deletions(-) create mode 100644 apps/server-asset-sg/src/features/files/file.repo.spec.ts create mode 100644 apps/server-asset-sg/src/features/files/file.repo.ts create mode 100644 libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.html create mode 100644 libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.scss create mode 100644 libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.ts create mode 100644 libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.html create mode 100644 libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.scss create mode 100644 libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.ts create mode 100644 libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.html create mode 100644 libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.scss create mode 100644 libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.ts create mode 100644 libs/persistence/prisma/migrations/20241001062933_add_legal_files/migration.sql diff --git a/.gitignore b/.gitignore index a18c045e..c3605222 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ __pycache__ # NX cache .nx/ + +# Asset sync progress file +asset-sync-progress.tmp.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3245187a..1b4857a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,17 @@ - Anonymer Modus für view-assets - Synchronisations-Service zwischen verschiedenen Instanzen prod, prod-extern, prod-view +- Dokumente für rechtliche Einwilligungen (_Legal Docs_) können nun separat von normalen Dateien + hochgeladen und angezeigt werden. Diese Einwilligungen können zusätzlich mit einem Typ versehen werden, + der die Art von Dokument wiederspiegelt. ### Changed - Admins haben nun auf alle Arbeitsgruppen Leserechte anstatt Schreibrechte - für das Schreiben muss ein Admin sich der Arbeitsgruppe hinzufügen - Der Button für Polygon-Filter ist nun links bei den restlichen Filtern - Dependency Updates +- Existierende Dateien, welche mit `_LDoc.pdf` enden, + wurden als rechtliche Einwilligung mit Typ `permissionForm` markiert. ### Fixed diff --git a/README.md b/README.md index 24e76791..17c04e2a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SwissGeol Asset +# SwissGeol Assets ## Development @@ -12,16 +12,11 @@ The following components must be installed on the development computer: Follow these steps to set up the development environment on your local machine: -- [1. Configure Local Systems](#1-Configure-Local-Systems) -- [2. Install Dependencies](#2-Install-Dependencies) -- [3. Generate Database Types](#3-Generate-Database-Types) -- [4. Initialize MinIO](#4-Initialize-MinIO) +- [1. Install Dependencies](#2-Install-Dependencies) +- [2. Generate Database Types](#3-Generate-Database-Types) +- [3. Initialize MinIO](#4-Initialize-MinIO) -#### 1. Configure Local Systems - -Configure `development/.env` according to the [local service configuration](#Local-Service-Configuration). - -#### 2. Install Dependencies +#### 1. Install Dependencies Install node modules: @@ -29,7 +24,7 @@ Install node modules: npm install ``` -#### 3. Generate Database Types +#### 2. Generate Database Types Generate prisma-client for database-access: @@ -37,7 +32,10 @@ Generate prisma-client for database-access: npm run prisma -- generate ``` -#### 4. Initialize MinIO +#### 3. Initialize MinIO + +> Note that this step may be skipped if you do not need to interact with uploaded files, +> and don't want to upload files yourselves. - [Start the development services](#Starting-the-Development-Environment). - Open http://localhost:9001 @@ -46,7 +44,7 @@ npm run prisma -- generate - Navigate to [the new bucket's browser](http://localhost:9001/browser/asset-sg) and create an empty folder with the name `asset-sg`. - Navigate to [Configuration](http://localhost:9001/settings/configurations/region) and change the server region to `local`. - Navigate to [Access Keys](http://localhost:9001/access-keys) and create a new access key. -- Open the file [`apps/server-asset-sg/.env.local`](apps/server-asset-sg/.env.local) and modify the following variables: +- Create the file [`apps/server-asset-sg/.env.local`](apps/server-asset-sg/.env.local) and add the following variables: - Set `S3_ACCESS_KEY_ID` to your generated access key. - Set `S3_SECRET_ACCESS_KEY` to your generated access key's secret. @@ -150,10 +148,10 @@ nx run server-asset-sg:test -t 'AssetRepo create' ## Configuration -### Asset Server Configuration +### Server Configuration -The file `apps/server-asset-sg/.env.local` configures secrets for the SwissGeol Asset server. -An empty template for the file can be found in [`apps/server-asset-sg/.env.template`](apps/server-asset-sg/.env.template). +The file `apps/server-asset-sg/.env` configures the configuration for the SwissGeol Assets server. +By default, it is configured to work with the Docker services found in [`development/docker-compose.yml`](development/docker-compose.yml). | Variable | Example | Description | | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- | @@ -174,25 +172,33 @@ An empty template for the file can be found in [`apps/server-asset-sg/.env.templ | OCR_URL | | Leave empty. | | OCR_CALLBACK_URL | | Leave empty. | -> The local docker configuration contains an OIDC container supporting OAuth. -> Use the example values to use it instead of an external issuer. +### Services Configuration -### Local Service Configuration +The file [`development/.env`](development/.env) configures secrets for the services used in local development. +By default, these secrets align with the server's configuration. -The file `development/.env` configures secrets for the services used in local development. -An empty template for the file can be found in [`development/.env.template`](development/.env.template). +| Variable | Beschreibung | +| ---------------- | -------------------------------------- | +| STORAGE_USER | Username for the MinIO container. | +| STORAGE_PASSWORD | Password for the MinIO container. | +| DB_USER | Username for the PostgreSQL container. | +| DB_PASSWORD | Password for the PostgreSQL container. | +| PGADMIN_EMAIL | Email for the PgAdmin container. | +| PGADMIN_PASSWORD | Password for the PgAdmin container. | -> Make sure that your passwords have a minimal length of 8 and contain at combination of -> upper, lower and special characters. Some of the passwords will be checked for validity during startup. +```bash +git update-index --no-skip-worktree development/.env +git update-index --no-skip-worktree apps/server-asset-sg/.env.local +``` + +Then, after having committed your changes, remove them again: + +```bash +git update-index --skip-worktree development/.env +git update-index --skip-worktree apps/server-asset-sg/.env.local +``` -| Variable | Wert | Beschreibung | -| ---------------- | -------- | -------------------------------------- | -| STORAGE_USER | _custom_ | Username for the MinIO container. | -| STORAGE_PASSWORD | _custom_ | Password for the MinIO container. | -| DB_USER | postgres | Username for the PostgreSQL container. | -| DB_PASSWORD | _custom_ | Password for the PostgreSQL container. | -| PGADMIN_EMAIL | _custom_ | Email for the PgAdmin container. | -| PGADMIN_PASSWORD | _custom_ | Password for the PgAdmin container. | +> Note that worktree modifications need to be committed, just like file changes. ## Database ORM diff --git a/apps/client-asset-sg/src/app/i18n/de.ts b/apps/client-asset-sg/src/app/i18n/de.ts index 40dc6e5f..b191bb24 100644 --- a/apps/client-asset-sg/src/app/i18n/de.ts +++ b/apps/client-asset-sg/src/app/i18n/de.ts @@ -84,6 +84,7 @@ export const deAppTranslations = { workgroup: 'Arbeitsgruppe', resetSearch: 'Suche zurücksetzen', file: 'Datei', + legalFile: 'Rechtliche Einwilligungen', openFileInNewTab: '{{fileName}} in neuem Tab öffnen', downloadFile: '{{fileName}} herunterladen', assetsUnderMouseCursor: '{{ assetsCount }} Assets unter dem Mauszeiger gefunden. Bitte wählen Sie eines aus:', @@ -117,9 +118,20 @@ export const deAppTranslations = { alternativeId: 'Alternativ-ID', alternativeIdDescription: 'Beschreibung Alternativ-ID', addNewAlternativeId: 'Neue Alternativ-ID hinzufügen', - files: 'Dateien', - dragFileHere: 'Datei hierher ziehen', + }, + files: { + tabName: 'Dateien', + Normal: { + one: 'Normale Datei', + many: 'Normale Dateien', + }, + Legal: { + one: 'Rechtliche Einwilligung', + many: 'Rechtliche Einwilligungen', + }, + legalDocItemCode: 'Typ', or: 'oder', + dragFileHere: 'Datei hierher ziehen', selectFile: 'Datei auswählen', addNewFile: 'Neue Datei hinzufügen', willBeDeleted: 'Wird gelöscht werden', diff --git a/apps/client-asset-sg/src/app/i18n/en.ts b/apps/client-asset-sg/src/app/i18n/en.ts index 917af780..c2b7b753 100644 --- a/apps/client-asset-sg/src/app/i18n/en.ts +++ b/apps/client-asset-sg/src/app/i18n/en.ts @@ -85,6 +85,7 @@ export const enAppTranslations: AppTranslations = { workgroup: 'Workgroup', resetSearch: 'Reset search', file: 'File', + legalFile: 'Legal consent', openFileInNewTab: 'Open {{fileName}} in new tab', downloadFile: 'Download {{fileName}}', assetsUnderMouseCursor: '{{ assetsCount }} assets found under the mouse cursor. Please select one:', @@ -118,7 +119,18 @@ export const enAppTranslations: AppTranslations = { alternativeId: 'Alternative ID', alternativeIdDescription: 'Alternative ID Description', addNewAlternativeId: 'Add new alternative ID', - files: 'Files', + }, + files: { + tabName: 'Files', + Normal: { + one: 'Normal File', + many: 'Normal Files', + }, + Legal: { + one: 'Legal consent', + many: 'Legal consents', + }, + legalDocItemCode: 'Type', dragFileHere: 'Drag file here', or: 'or', selectFile: 'Select file', diff --git a/apps/client-asset-sg/src/app/i18n/fr.ts b/apps/client-asset-sg/src/app/i18n/fr.ts index 1722c036..10f95b89 100644 --- a/apps/client-asset-sg/src/app/i18n/fr.ts +++ b/apps/client-asset-sg/src/app/i18n/fr.ts @@ -85,6 +85,7 @@ export const frAppTranslations: AppTranslations = { workgroup: 'groupe de travail', resetSearch: 'Réinitialiser la recherche', file: 'Fichier', + legalFile: 'Consentements légaux', openFileInNewTab: 'Ouvrir {{fileName}} dans un nouvel onglet', downloadFile: 'Télécharger {{fileName}}', assetsUnderMouseCursor: @@ -119,7 +120,18 @@ export const frAppTranslations: AppTranslations = { alternativeId: 'ID alternative', alternativeIdDescription: "Description d'ID alternative", addNewAlternativeId: 'Ajouter une nouvelle ID alternative', - files: 'Fichiers', + }, + files: { + tabName: 'Fichiers', + Normal: { + one: 'Fichier normal', + many: 'Fichiers normaux', + }, + Legal: { + one: 'Consentement Légal', + many: 'Consentements Légaux', + }, + legalDocItemCode: 'Type', dragFileHere: 'Glisser le fichier ici', or: 'ou', selectFile: 'Sélectionner un fichier', diff --git a/apps/client-asset-sg/src/app/i18n/it.ts b/apps/client-asset-sg/src/app/i18n/it.ts index c0bbdae8..6f879fdf 100644 --- a/apps/client-asset-sg/src/app/i18n/it.ts +++ b/apps/client-asset-sg/src/app/i18n/it.ts @@ -85,6 +85,7 @@ export const itAppTranslations: AppTranslations = { workgroup: 'IT Arbeitsgruppe', resetSearch: 'IT Suche zurücksetzen', file: 'IT Datei', + legalFile: 'IT Rechtliche Einwilligungen', openFileInNewTab: 'IT {{fileName}} in neuem Tab öffnen', downloadFile: 'IT {{fileName}} herunterladen', assetsUnderMouseCursor: 'IT {{ assetsCount }} Assets unter dem Mauszeiger gefunden. Bitte wählen Sie eines aus:', @@ -118,14 +119,25 @@ export const itAppTranslations: AppTranslations = { alternativeId: 'IT Alternativ-ID', alternativeIdDescription: 'IT Beschreibung Alternativ-ID', addNewAlternativeId: 'IT Neue Alternativ-ID hinzufügen', - files: 'IT Dateien', + }, + files: { + tabName: 'IT Dateien', + Normal: { + one: 'IT Normale Datei', + many: 'IT Normale Dateien', + }, + Legal: { + one: 'IT Rechtliche Einwilligung', + many: 'IT Rechtliche Einwilligungen', + }, + legalDocItemCode: 'IT Typ', dragFileHere: 'IT Datei hierher ziehen', or: 'IT oder', selectFile: 'IT Datei auswählen', addNewFile: 'IT Neue Datei hinzufügen', willBeDeleted: 'IT Wird gelöscht werden', willBeUploaded: 'IT Wird hochgeladen werden', - fileSizeToLarge: 'IT Die Dateigrösse darf 250MB nicht überschreiten.', + fileSizeToLarge: 'IT Die Dateigrösse darf 250 MB nicht überschreiten.', }, usage: { tabName: 'IT Nutzung', diff --git a/apps/client-asset-sg/src/app/i18n/rm.ts b/apps/client-asset-sg/src/app/i18n/rm.ts index c375c1b2..8cd77e7c 100644 --- a/apps/client-asset-sg/src/app/i18n/rm.ts +++ b/apps/client-asset-sg/src/app/i18n/rm.ts @@ -85,6 +85,7 @@ export const rmAppTranslations: AppTranslations = { workgroup: 'RM Arbeitsgruppe', resetSearch: 'RM Suche zurücksetzen', file: 'RM Datei', + legalFile: 'RM Rechtliche Einwilligungen', openFileInNewTab: 'RM {{fileName}} in neuem Tab öffnen', downloadFile: 'RM {{fileName}} herunterladen', assetsUnderMouseCursor: 'RM {{ assetsCount }} Assets unter dem Mauszeiger gefunden. Bitte wählen Sie eines aus:', @@ -118,14 +119,25 @@ export const rmAppTranslations: AppTranslations = { alternativeId: 'RM Alternativ-ID', alternativeIdDescription: 'RM Beschreibung Alternativ-ID', addNewAlternativeId: 'RM Neue Alternativ-ID hinzufügen', - files: 'RM Dateien', + }, + files: { + tabName: 'RM Dateien', + Normal: { + one: 'RM Normale Datei', + many: 'RM Normale Dateien', + }, + Legal: { + one: 'RM Rechtliche Einwilligung', + many: 'RM Rechtliche Einwilligungen', + }, + legalDocItemCode: 'RM Typ', dragFileHere: 'RM Datei hierher ziehen', - or: 'FRM oder', + or: 'RM oder', selectFile: 'RM Datei auswählen', addNewFile: 'RM Neue Datei hinzufügen', willBeDeleted: 'RM Wird gelöscht werden', willBeUploaded: 'RM Wird hochgeladen werden', - fileSizeToLarge: 'RM Die Dateigrösse darf 250MB nicht überschreiten.', + fileSizeToLarge: 'RM Die Dateigrösse darf 250 MB nicht überschreiten.', }, usage: { tabName: 'RM Nutzung', diff --git a/apps/server-asset-sg/.gitignore b/apps/server-asset-sg/.gitignore index 8a6df9dc..ae265f4e 100644 --- a/apps/server-asset-sg/.gitignore +++ b/apps/server-asset-sg/.gitignore @@ -1 +1,2 @@ http-client.env.json +.env.local diff --git a/apps/server-asset-sg/src/app.module.ts b/apps/server-asset-sg/src/app.module.ts index abd27cfe..0fc161bb 100644 --- a/apps/server-asset-sg/src/app.module.ts +++ b/apps/server-asset-sg/src/app.module.ts @@ -23,6 +23,7 @@ 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 { FileRepo } from '@/features/files/file.repo'; import { FilesController } from '@/features/files/files.controller'; import { OcrController } from '@/features/ocr/ocr.controller'; import { StudiesController } from '@/features/studies/studies.controller'; @@ -58,6 +59,7 @@ import { WorkgroupsController } from '@/features/workgroups/workgroups.controlle AssetSyncService, ContactRepo, FavoriteRepo, + FileRepo, PrismaService, StudyRepo, UserRepo, diff --git a/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts index eb182a55..46387787 100644 --- a/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts @@ -52,6 +52,7 @@ export const fakeContact = () => export const fakeAssetPatch = (): PatchAsset => ({ assetContacts: [], + assetFiles: [], assetFormatItemCode: fakeAssetFormatItemCode(), assetKindItemCode: fakeAssetKindItemCode(), assetMainId: O.none, diff --git a/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.ts index 91369073..e03cd953 100644 --- a/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.repo.ts @@ -33,17 +33,6 @@ export class AssetEditRepo implements Repo { - 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, @@ -158,6 +147,18 @@ export class AssetEditRepo implements Repo ({ + data: { + file: { + update: { + ...it, + }, + }, + }, + where: { assetId_fileId: { assetId: id, fileId: it.id } }, + })), + }, ids: { deleteMany: { idId: { @@ -367,7 +368,10 @@ const selectPrismaAsset = selectOnAsset({ siblingXAssets: { select: { assetY: { select: { assetId: true, titlePublic: true } } } }, siblingYAssets: { select: { assetX: { select: { assetId: true, titlePublic: true } } } }, statusWorks: { select: { statusWorkItemCode: true, statusWorkDate: true } }, - assetFiles: { select: { file: true } }, + assetFiles: { + select: { file: { select: { id: true, name: true, size: true, type: true, legalDocItemCode: true } } }, + orderBy: [{ file: { type: 'asc' } }, { file: { name: 'asc' } }], + }, workgroupId: true, }); diff --git a/apps/server-asset-sg/src/features/asset-edit/asset-edit.service.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.service.ts index c7d90c36..21d2ab25 100644 --- a/apps/server-asset-sg/src/features/asset-edit/asset-edit.service.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.service.ts @@ -7,12 +7,8 @@ import * as TE from 'fp-ts/TaskEither'; import * as C from 'io-ts/Codec'; import { AssetEditRepo } from './asset-edit.repo'; - -import { PrismaService } from '@/core/prisma.service'; import { AssetSearchService } from '@/features/assets/search/asset-search.service'; import { notFoundError } from '@/utils/errors'; -import { deleteFile } from '@/utils/file/delete-file'; -import { putFile } from '@/utils/file/put-file'; export const AssetEditDetail = C.struct({ ...BaseAssetEditDetail, @@ -22,11 +18,7 @@ export type AssetEditDetail = C.TypeOf; @Injectable() export class AssetEditService { - constructor( - private readonly assetEditRepo: AssetEditRepo, - private readonly prismaService: PrismaService, - private readonly assetSearchService: AssetSearchService - ) {} + constructor(private readonly assetEditRepo: AssetEditRepo, private readonly assetSearchService: AssetSearchService) {} public createAsset(user: User, patch: PatchAsset) { return pipe( @@ -46,103 +38,4 @@ export class AssetEditService { TE.map((asset) => AssetEditDetail.encode(asset)) ); } - - public uploadFile( - user: User, - assetId: number, - file: { name: string; buffer: Buffer; size: number; mimetype: string } - ) { - const originalFileName = Buffer.from(file.name, 'latin1').toString('utf8'); - const fileName = originalFileName.startsWith('a' + assetId + '_') - ? originalFileName - : 'a' + assetId + '_' + originalFileName; - - const lastDotIndex = fileName.lastIndexOf('.'); - const fileExtension = lastDotIndex === -1 ? '' : fileName.substring(lastDotIndex + 1); - const fileNameWithoutExtension = lastDotIndex === -1 ? fileName : fileName.substring(0, lastDotIndex); - const match = fileNameWithoutExtension.match(/_([0-9]{14})$/); - const fileNameWithoutTs = match ? fileNameWithoutExtension.substring(0, match.index) : fileNameWithoutExtension; - const filenameToSearchFor = fileNameWithoutTs + '.' + fileExtension; - - return pipe( - TE.tryCatch( - () => - this.prismaService.file.findFirst({ - where: { - fileName: { - contains: filenameToSearchFor, - }, - }, - }), - unknownToUnknownError - ), - TE.map((file) => { - const d = new Date(); - return file - ? fileNameWithoutTs + - '_' + - d.getFullYear() + - (d.getMonth() + 1).toString().padStart(2, '0') + - d.getDate().toString().padStart(2, '0') + - d.getHours().toString().padStart(2, '0') + - d.getMinutes().toString().padStart(2, '0') + - d.getSeconds().toString().padStart(2, '0') + - '.' + - fileExtension - : fileName; - }), - TE.bindTo('_fileName'), - TE.bind('updateResult', ({ _fileName }) => - TE.tryCatch(() => { - return this.prismaService.asset.update({ - where: { assetId }, - data: { - assetFiles: { - create: { - file: { - create: { - fileName: _fileName, - fileSize: file.size, - ocrStatus: file.mimetype == 'application/pdf' ? 'waiting' : 'willNotBeProcessed', - lastModified: new Date(), - }, - }, - }, - }, - lastProcessedDate: new Date(), - processor: user.email, - }, - }); - }, unknownToUnknownError) - ), - TE.chain(({ _fileName }) => putFile(_fileName, file.buffer, file.mimetype)) - ); - } - - public deleteFile(user: User, assetId: number, fileId: number) { - return pipe( - TE.tryCatch( - () => this.prismaService.file.findFirstOrThrow({ where: { fileId }, select: { fileName: true } }), - unknownToUnknownError - ), - TE.chain((f) => deleteFile(f.fileName)), - TE.chain(() => - TE.tryCatch( - () => this.prismaService.assetFile.delete({ where: { assetId_fileId: { assetId, fileId } } }), - unknownToUnknownError - ) - ), - TE.chain(() => TE.tryCatch(() => this.prismaService.file.delete({ where: { fileId } }), unknownToUnknownError)), - TE.chain(() => - TE.tryCatch( - () => - this.prismaService.asset.update({ - where: { assetId }, - data: { lastProcessedDate: new Date(), processor: user.email }, - }), - unknownToUnknownError - ) - ) - ); - } } 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 65518142..00c940c8 100644 --- a/apps/server-asset-sg/src/features/assets/prisma-asset.ts +++ b/apps/server-asset-sg/src/features/assets/prisma-asset.ts @@ -88,9 +88,9 @@ export const assetInfoSelection = satisfy()({ select: { file: { select: { - fileId: true, - fileName: true, - fileSize: true, + id: true, + name: true, + size: true, }, }, }, @@ -158,9 +158,9 @@ export const parseAssetInfoFromPrisma = (data: SelectedAssetInfo): AssetInfo => ], }, files: data.assetFiles.map((it) => ({ - id: it.file.fileId, - size: Number(it.file.fileSize), - name: it.file.fileName, + id: it.file.id, + size: Number(it.file.size), + name: it.file.name, })), createdAt: LocalDate.fromDate(data.createDate), receivedAt: LocalDate.fromDate(data.receiptDate), diff --git a/apps/server-asset-sg/src/features/files/file.repo.spec.ts b/apps/server-asset-sg/src/features/files/file.repo.spec.ts new file mode 100644 index 00000000..9abbbc42 --- /dev/null +++ b/apps/server-asset-sg/src/features/files/file.repo.spec.ts @@ -0,0 +1,66 @@ +import { faker } from '@faker-js/faker'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { clearPrismaAssets, setupDB } from '../../../../../test/setup-db'; +import { PrismaService } from '@/core/prisma.service'; +import { determineUniqueFilename, FileRepo } from '@/features/files/file.repo'; + +describe(FileRepo, () => { + const prisma = new PrismaService(); + + beforeAll(async () => { + await setupDB(prisma); + }); + + beforeEach(async () => { + await clearPrismaAssets(prisma); + }); + + describe('determineUniqueFilename', () => { + it("prepends the assetId to the file's name", async () => { + // Given + const assetId = faker.number.int({ min: 1 }); + const original = 'my_file.name.exe'; + + // When + const actual = await determineUniqueFilename(original, assetId, prisma); + + // Then + expect(actual).toEqual(`a${assetId}_${original}`); + }); + + it('uses the name as-is if it is already prefixed with the assetId', async () => { + // Given + const assetId = faker.number.int({ min: 1 }); + const original = `a${assetId}_some-Other.filename.123`; + + // When + const actual = await determineUniqueFilename(original, assetId, prisma); + + // Then + expect(actual).toEqual(original); + }); + + it('makes the file name unique by appending the current date and time', async () => { + // Given + const fileName = 'name'; + const fileExt = 'txt'; + const fullFileName = `${fileName}.${fileExt}`; + const assetId = faker.number.int({ min: 1 }); + + // When + await prisma.file.create({ + data: { + name: `a${assetId}_${fullFileName}`, + size: faker.number.int({ min: 0 }), + lastModifiedAt: faker.date.past(), + type: faker.helpers.arrayElement(['Normal', 'Legal']), + ocrStatus: 'willNotBeProcessed', + }, + }); + const actual = await determineUniqueFilename(fullFileName, assetId, prisma); + + // Then + expect(actual).toMatch(new RegExp(`^a${assetId}_${fileName}_\\d{4}\\d{2}\\d{2}\\d{2}\\d{2}\\d{2}\\.${fileExt}$`)); + }); + }); +}); diff --git a/apps/server-asset-sg/src/features/files/file.repo.ts b/apps/server-asset-sg/src/features/files/file.repo.ts new file mode 100644 index 00000000..97745156 --- /dev/null +++ b/apps/server-asset-sg/src/features/files/file.repo.ts @@ -0,0 +1,159 @@ +import { AssetFile, AssetFileType, LegalDocItemCode } from '@asset-sg/shared'; +import { AssetId, User } from '@asset-sg/shared/v2'; +import { Injectable } from '@nestjs/common'; +import * as E from 'fp-ts/Either'; +import { PrismaService } from '@/core/prisma.service'; +import { CreateRepo, DeleteRepo } from '@/core/repo'; +import { deleteFile } from '@/utils/file/delete-file'; +import { putFile } from '@/utils/file/put-file'; + +@Injectable() +export class FileRepo implements CreateRepo, DeleteRepo { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateFileData): Promise { + const name = await determineUniqueFilename(data.name, data.assetId, this.prisma); + const isOcrCompatible = data.type !== 'Legal' && data.mediaType == 'application/pdf'; + const { id } = await this.prisma.file.create({ + select: { id: true }, + data: { + name, + size: data.size, + ocrStatus: isOcrCompatible ? 'waiting' : 'willNotBeProcessed', + type: data.type, + legalDocItemCode: data.legalDocItemCode, + lastModifiedAt: new Date(), + }, + }); + await this.prisma.asset.update({ + select: { assetId: true }, + where: { assetId: data.assetId }, + data: { + assetFiles: { + create: { + fileId: id, + }, + }, + lastProcessedDate: new Date(), + processor: data.user.email, + }, + }); + const result = await putFile(data.name, data.content, data.mediaType)(); + if (E.isLeft(result)) { + throw result.left; + } + return { + id, + name, + size: data.size, + type: data.type, + legalDocItemCode: data.legalDocItemCode, + }; + } + + delete({ id, assetId, user }: DeleteFileData): Promise { + return this.prisma.$transaction(async () => { + const fileName = ( + await this.prisma.file.findUnique({ + where: { id }, + select: { name: true }, + }) + )?.name; + if (fileName == null) { + return false; + } + await this.prisma.assetFile.delete({ + where: { + assetId_fileId: { assetId, fileId: id }, + }, + select: { fileId: true }, + }); + + // Check if the file is assigned to any other assets. + // In that case, we can't delete it. + // This is necessary since files can be assigned to multiple assets at the database level. + const isFileUnused = + null != + this.prisma.assetFile.findFirst({ + where: { fileId: id, assetId: { not: assetId } }, + select: { fileId: true }, + }); + if (isFileUnused) { + await this.prisma.file.delete({ + where: { id }, + select: { id: true }, + }); + const result = await deleteFile(fileName)(); + if (E.isLeft(result)) { + throw result.left; + } + } + + // Update the processor fields on the file's asset. + this.prisma.asset.update({ + where: { assetId }, + data: { lastProcessedDate: new Date(), processor: user.email }, + select: { assetId: true }, + }); + + return true; + }); + } +} + +interface CreateFileData { + name: string; + size: number; + type: AssetFileType; + legalDocItemCode: LegalDocItemCode | null; + mediaType: string; + content: Buffer; + assetId: AssetId; + user: User; +} + +interface DeleteFileData { + id: number; + assetId: AssetId; + user: User; +} + +export const determineUniqueFilename = async ( + fileName: string, + assetId: AssetId, + prisma: PrismaService +): Promise => { + const name = fileName.startsWith('a' + assetId + '_') ? fileName : 'a' + assetId + '_' + fileName; + + const lastDotIndex = name.lastIndexOf('.'); + const extension = lastDotIndex === -1 ? '' : name.substring(lastDotIndex + 1); + const nameWithoutExtension = lastDotIndex === -1 ? name : name.substring(0, lastDotIndex); + const match = /_(\d{14})$/.exec(nameWithoutExtension); + const nameWithoutTs = match ? nameWithoutExtension.substring(0, match.index) : nameWithoutExtension; + const nameToSearchFor = `${nameWithoutTs}.${extension}`; + + const isUniqueName = + null === + (await prisma.file.findFirst({ + select: { id: true }, + where: { + name: { + contains: nameToSearchFor, + }, + }, + })); + if (isUniqueName) { + return name; + } + const now = new Date(); + const pad = (value: number): string => value.toString().padStart(2, '0'); + return ( + `${nameWithoutTs}_${now.getFullYear()}` + + `${pad(now.getMonth() + 1)}` + + `${pad(now.getDate())}` + + `${pad(now.getHours())}` + + `${pad(now.getMinutes())}` + + `${pad(now.getSeconds())}` + + `.${extension}` + ); +}; diff --git a/apps/server-asset-sg/src/features/files/files.controller.ts b/apps/server-asset-sg/src/features/files/files.controller.ts index 53adf9df..20bda4a3 100644 --- a/apps/server-asset-sg/src/features/files/files.controller.ts +++ b/apps/server-asset-sg/src/features/files/files.controller.ts @@ -1,9 +1,10 @@ -import { User } from '@asset-sg/shared/v2'; -import { AssetEditPolicy } from '@asset-sg/shared/v2'; +import { AssetFile, AssetFileType, LegalDocItemCode } from '@asset-sg/shared'; +import { AssetEditPolicy, User } from '@asset-sg/shared/v2'; import { Controller, Delete, Get, + HttpCode, HttpException, HttpStatus, Param, @@ -17,25 +18,32 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; import * as E from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; +import * as D from 'io-ts/Decoder'; 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 { FileRepo } from '@/features/files/file.repo'; import { getFile } from '@/utils/file/get-file'; -@Controller('/files') +@Controller('/assets/:assetId/files') export class FilesController { constructor( - private readonly assetEditService: AssetEditService, + private readonly fileRepo: FileRepo, 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) { + async download( + @Param('assetId', ParseIntPipe) assetId: number, + @Param('id', ParseIntPipe) id: number, + @Res() res: Response, + @CurrentUser() user: User + ) { + const asset = await this.assetEditRepo.find(assetId); + if (asset == null || null === asset.assetFiles.find((it) => it.id === id)) { throw new HttpException('not found', HttpStatus.NOT_FOUND); } authorize(AssetEditPolicy, user).canShow(asset); @@ -57,41 +65,73 @@ export class FilesController { @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); - } + async upload( + @Param('assetId', ParseIntPipe) assetId: number, + @Req() req: Request, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: User + ) { 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, { + const type = pipe( + AssetFileType.decode((req.body as { type?: string }).type ?? ''), + E.getOrElseW(() => null) + ); + if (type == null) { + throw new HttpException('invalid type', HttpStatus.BAD_REQUEST); + } + + const legalDocItemCode = pipe( + D.nullable(LegalDocItemCode).decode((req.body as { legalDocItemCode?: string }).legalDocItemCode ?? null), + E.getOrElseW(() => false as const) + ); + if (legalDocItemCode === false) { + throw new HttpException('invalid legalDocItemCode', HttpStatus.BAD_REQUEST); + } + switch (type) { + case 'Legal': { + if (legalDocItemCode == null) { + throw new HttpException('missing legalDocItemCode for legal file', HttpStatus.BAD_REQUEST); + } + break; + } + case 'Normal': + if (legalDocItemCode != null) { + throw new HttpException('legalDocItemCode is not supported for normal files', HttpStatus.BAD_REQUEST); + } + break; + } + + const record = await this.fileRepo.create({ name: file.originalname, - buffer: file.buffer, + type: type, size: file.size, - mimetype: file.mimetype, - })(); - if (E.isLeft(result)) { - throw new HttpException(result.left.message, 500); - } - return result.right; + legalDocItemCode, + mediaType: file.mimetype, + content: file.buffer, + assetId: asset.assetId, + user, + }); + return AssetFile.encode(record); } @Delete('/:id') - async delete(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { - const asset = await this.assetEditRepo.findByFile(id); + @HttpCode(HttpStatus.NO_CONTENT) + async delete( + @Param('assetId', ParseIntPipe) assetId: number, + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: User + ) { + const asset = await this.assetEditRepo.find(assetId); 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; + await this.fileRepo.delete({ id, assetId: asset.assetId, user }); } } diff --git a/apps/server-asset-sg/src/features/ocr/ocr.controller.ts b/apps/server-asset-sg/src/features/ocr/ocr.controller.ts index e2c5d3ea..2c5af96c 100644 --- a/apps/server-asset-sg/src/features/ocr/ocr.controller.ts +++ b/apps/server-asset-sg/src/features/ocr/ocr.controller.ts @@ -4,14 +4,13 @@ import { BadRequestException, Body, Controller, + createParamDecorator, ExecutionContext, Injectable, Logger, Param, Post, - createParamDecorator, } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; import type { AxiosRequestConfig } from 'axios'; import * as E from 'fp-ts/Either'; import { pipe } from 'fp-ts/function'; @@ -28,9 +27,7 @@ const BufferBody = createParamDecorator(async (_, context: ExecutionContext) => if (!req.readable) { throw new BadRequestException('Invalid body'); } - - const body = await streamToBufferAsync(req); - return body; + return await streamToBufferAsync(req); }); const Config = D.struct({ @@ -42,9 +39,9 @@ type Config = D.TypeOf; @Injectable() @Controller('ocr') export class OcrController { - private config: Config; + private readonly config: Config; - constructor(private prismaService: PrismaService, private httpService: HttpService) { + constructor(private readonly prismaService: PrismaService, private readonly httpService: HttpService) { this.config = pipe( Config.decode({ ocrUrl: process.env.OCR_URL, @@ -70,8 +67,8 @@ export class OcrController { TE.tryCatch( () => this.prismaService.file.update({ - where: { fileId: Number(fileId) }, - data: { ocrStatus: 'success', fileSize: body.length, lastModified: new Date() }, + where: { id: Number(fileId) }, + data: { ocrStatus: 'success', size: body.length, lastModifiedAt: new Date() }, }), unknownToUnknownError ) @@ -90,15 +87,15 @@ export class OcrController { await TE.tryCatch( () => this.prismaService.file.update({ - where: { fileId: Number(fileId) }, - data: { ocrStatus: 'error', lastModified: new Date() }, + where: { id: Number(fileId) }, + data: { ocrStatus: 'error', lastModifiedAt: new Date() }, }), unknownToUnknownError )(); Logger.warn('OCR Job Error for file: ' + filename); } - @Cron(CronExpression.EVERY_30_SECONDS) + // @Cron(CronExpression.EVERY_30_SECONDS) async handleCron() { Logger.log('cron job running'); const result = await pipe( @@ -111,19 +108,19 @@ export class OcrController { ), TE.filterOrElseW(isNotNil, () => ({ _tag: 'nothingToDo' as const })), TE.bindTo('file'), - TE.bindW('s3File', ({ file }) => getFile(this.prismaService, file.fileId)), + TE.bindW('s3File', ({ file }) => getFile(this.prismaService, file.id)), TE.bindW('buffer', ({ s3File }) => streamToBufferTE(s3File.stream)), TE.bindW('updateResult', ({ file }) => TE.tryCatch( () => this.prismaService.file.update({ - where: { fileId: file.fileId }, - data: { ocrStatus: 'processing', lastModified: new Date() }, + where: { id: file.id }, + data: { ocrStatus: 'processing', lastModifiedAt: new Date() }, }), unknownToUnknownError ) ), - TE.chainW(({ buffer, file }) => this.extractText(buffer, file.fileId, file.fileName)) + TE.chainW(({ buffer, file }) => this.extractText(buffer, file.id, file.name)) )(); Logger.log('result', result); } diff --git a/apps/server-asset-sg/src/models/AssetDetailFromPostgres.ts b/apps/server-asset-sg/src/models/AssetDetailFromPostgres.ts index 3ec51c48..1cbe739d 100644 --- a/apps/server-asset-sg/src/models/AssetDetailFromPostgres.ts +++ b/apps/server-asset-sg/src/models/AssetDetailFromPostgres.ts @@ -1,11 +1,33 @@ import { DT } from '@asset-sg/core'; -import { AssetContactRole, DateIdFromDate, LinkedAsset, makeUsageCode } from '@asset-sg/shared'; +import { + AssetContactRole, + AssetFileType, + DateIdFromDate, + LegalDocItemCode, + LinkedAsset, + makeUsageCode, +} from '@asset-sg/shared'; import { pipe } from 'fp-ts/function'; import * as C from 'io-ts/Codec'; import * as D from 'io-ts/Decoder'; import { PostgresAllStudies } from '@/utils/postgres-studies/postgres-studies'; +export const AssetFilesFromPostgres = pipe( + D.array( + D.struct({ + file: D.struct({ + id: D.number, + name: D.string, + size: DT.numberFromBigint, + type: AssetFileType, + legalDocItemCode: D.nullable(LegalDocItemCode), + }), + }) + ), + D.map((a) => a.map((b) => b.file)) +); + export const AssetDetailFromPostgres = pipe( D.struct({ assetId: D.number, @@ -99,18 +121,7 @@ export const AssetDetailFromPostgres = pipe( statusWorkDate: DT.date, }) ), - assetFiles: pipe( - D.array( - D.struct({ - file: D.struct({ - fileId: D.number, - fileName: D.string, - fileSize: DT.bigint, - }), - }) - ), - D.map((a) => a.map((b) => b.file)) - ), + assetFiles: AssetFilesFromPostgres, studies: PostgresAllStudies, }), D.map((a) => { 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 70ac607e..248ccd33 100644 --- a/apps/server-asset-sg/src/models/asset-edit-detail.ts +++ b/apps/server-asset-sg/src/models/asset-edit-detail.ts @@ -3,6 +3,7 @@ import { AssetContactEdit, AssetLanguageEdit, DateIdFromDate, LinkedAsset, Statu import { pipe } from 'fp-ts/function'; import * as D from 'io-ts/Decoder'; +import { AssetFilesFromPostgres } from '@/models/AssetDetailFromPostgres'; import { PostgresAllStudies } from '@/utils/postgres-studies/postgres-studies'; export const AssetEditDetailFromPostgres = pipe( @@ -81,18 +82,7 @@ export const AssetEditDetailFromPostgres = pipe( statusWorkDate: DT.date, }) ), - assetFiles: pipe( - D.array( - D.struct({ - file: D.struct({ - fileId: D.number, - fileName: D.string, - fileSize: DT.bigint, - }), - }) - ), - D.map((a) => a.map((b) => b.file)) - ), + assetFiles: AssetFilesFromPostgres, workgroupId: D.number, studies: PostgresAllStudies, }) diff --git a/apps/server-asset-sg/src/utils/file/get-file.ts b/apps/server-asset-sg/src/utils/file/get-file.ts index 02c3d48c..6c9c776d 100644 --- a/apps/server-asset-sg/src/utils/file/get-file.ts +++ b/apps/server-asset-sg/src/utils/file/get-file.ts @@ -12,15 +12,24 @@ export const getFile = (prismaClient: PrismaClient, fileId: number) => { createS3Client(), (s3Client) => pipe( - TE.tryCatch(() => pipe(prismaClient.file.findFirstOrThrow({ where: { fileId } })), unknownToError), - TE.chainW(({ fileName }) => { - const key = (assetFolder ? assetFolder + '/' : '') + fileName; + TE.tryCatch( + () => + pipe( + prismaClient.file.findFirstOrThrow({ + where: { id: fileId }, + select: { name: true }, + }) + ), + unknownToError + ), + TE.chainW(({ name }) => { + const key = (assetFolder ? assetFolder + '/' : '') + name; return pipe( TE.tryCatch( () => s3Client.send(new GetObjectCommand({ Key: key, Bucket: bucketName })), (e) => new TypedError(unknownErrorTag, e, 'Unable to get file from S3 with key ' + key) ), - TE.map((a) => ({ ...a, fileName })) + TE.map((a) => ({ ...a, fileName: name })) ); }), TE.map((a) => ({ diff --git a/development/.env b/development/.env index 5c83fb41..4c7eae60 100644 --- a/development/.env +++ b/development/.env @@ -1,5 +1,5 @@ STORAGE_USER=storage -STORAGE_PASSWORD=storage +STORAGE_PASSWORD=password DB_USER=postgres DB_PASSWORD=postgres PGADMIN_EMAIL=pg@admin.ch diff --git a/libs/asset-editor/src/lib/asset-editor.module.ts b/libs/asset-editor/src/lib/asset-editor.module.ts index 2e034d6e..d01dff4c 100644 --- a/libs/asset-editor/src/lib/asset-editor.module.ts +++ b/libs/asset-editor/src/lib/asset-editor.module.ts @@ -14,19 +14,19 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; import { CanActivateFn, CanDeactivateFn, RouterModule } from '@angular/router'; import { + AdminOnlyDirective, AnchorComponent, ButtonComponent, + DatepickerToggleIconComponent, DatePipe, DateTimePipe, - DatepickerToggleIconComponent, DrawerComponent, DrawerPanelComponent, + fromAppShared, MatDateIdModule, ValueItemDescriptionPipe, ValueItemNamePipe, ViewChildMarker, - fromAppShared, - AdminOnlyDirective, } from '@asset-sg/client-shared'; import { isNotNull } from '@asset-sg/core'; import { AssetEditPolicy } from '@asset-sg/shared/v2'; @@ -42,11 +42,13 @@ import { de } from 'date-fns/locale/de'; import * as O from 'fp-ts/Option'; import { combineLatest, filter, map } from 'rxjs'; +import { AssetEditorFilesComponent } from './components/asset-editor-files/asset-editor-files.component'; 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 { AssetEditorTabFilesComponent } from './components/asset-editor-tab-files/asset-editor-tab-files.component'; import { AssetEditorTabGeneralComponent } from './components/asset-editor-tab-general'; import { AssetEditorTabGeometriesComponent } from './components/asset-editor-tab-geometries'; import { AssetEditorTabPageComponent } from './components/asset-editor-tab-page'; @@ -56,17 +58,19 @@ 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'; +import { selectRDAssetEditDetail } from './state/asset-editor.selectors'; export const canLeaveEdit: CanDeactivateFn = (c) => c.canLeave(); @NgModule({ declarations: [ + AssetEditorFilesComponent, AssetEditorLaunchComponent, AssetEditorSyncComponent, AssetEditorPageComponent, AssetEditorTabAdministrationComponent, AssetEditorTabContactsComponent, + AssetEditorTabFilesComponent, AssetEditorTabGeneralComponent, AssetEditorTabGeometriesComponent, AssetEditorTabPageComponent, @@ -119,9 +123,7 @@ export const canLeaveEdit: CanDeactivateFn = (c) => c. (() => { const store = inject(Store); return combineLatest([ - store - .select(fromAssetEditor.selectRDAssetEditDetail) - .pipe(map(RD.toNullable), filter(isNotNull), map(O.toNullable)), + store.select(selectRDAssetEditDetail).pipe(map(RD.toNullable), filter(isNotNull), map(O.toNullable)), store.select(fromAppShared.selectUser).pipe(filter(isNotNull)), ]).pipe( map(([assetEditDetail, user]) => { diff --git a/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.html b/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.html new file mode 100644 index 00000000..824092e1 --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.html @@ -0,0 +1,80 @@ +
+
edit.tabs.files.{{ type }}.many
+
+
    +
  • +
    {{ file.name }}
    + + +
    + edit.tabs.files.willBeDeleted +
    +
    + + + + + + edit.tabs.files.legalDocItemCode + + + {{ item | valueItemName }} + + + + +
  • +
  • +
    {{ file.file.name }}
    + + + + + edit.tabs.files.legalDocItemCode + + + {{ item | valueItemName }} + + + + +
    edit.tabs.files.willBeUploaded
    +
  • +
+
+
+ + diff --git a/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.scss b/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.scss new file mode 100644 index 00000000..0463edcb --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.scss @@ -0,0 +1,61 @@ +@use "../../styles/variables"; +@use "../../styles/mixins"; + +:host { + display: grid; + grid-template-rows: 1fr auto; + grid-auto-rows: min-content; + align-content: start; + + background-color: variables.$white; + padding: 1rem 1.5rem; + margin: 0 2rem 1rem 0; + overflow-y: scroll; + max-height: 100%; + + width: 34rem; +} + +.files { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-right: 1rem; + + & > .name { + flex-grow: 1; + word-wrap: break-word; + } + + & > button[asset-sg-icon-button] { + flex-shrink: 1; + } + + & > .legal-doc-item-code { + width: 100%; + } + + & > .notice { + width: 100%; + } + + ::ng-deep .mat-mdc-select-min-line { + @include mixins.text-ellipsis; + } +} + +.upload { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + border: 2px dashed variables.$cyan-09; + padding: 0.5rem; + margin: 0 0.25rem 1rem 0; + align-self: end; +} + +.upload.is-disabled { + border-color: variables.$grey-03; +} diff --git a/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.ts b/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.ts new file mode 100644 index 00000000..e6e9c889 --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-files/asset-editor-files.component.ts @@ -0,0 +1,76 @@ +import { Component, inject, Input } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { fromAppShared } from '@asset-sg/client-shared'; +import * as RD from '@devexperts/remote-data-ts'; +import { Store } from '@ngrx/store'; +import { FileType } from '@prisma/client'; +import { filter, map } from 'rxjs'; +import { AssetEditorFile, AssetEditorFileTypeFormGroup, AssetEditorNewFile } from '../asset-editor-form-group'; + +@Component({ + selector: '[asset-sg-editor-files]', + templateUrl: './asset-editor-files.component.html', + styleUrl: './asset-editor-files.component.scss', +}) +export class AssetEditorFilesComponent { + @Input({ required: true }) + form!: AssetEditorFileTypeFormGroup; + + @Input({ required: true }) + type!: FileType; + + @Input({ required: true }) + isDisabled!: boolean; + + isFileTooLarge = false; + + private readonly store = inject(Store); + public readonly legalDocItems$ = this.store.select(fromAppShared.selectRDReferenceDataVM).pipe( + filter(RD.isSuccess), + map((a) => Object.values(a.value.legalDocItems)) + ); + + get hasFiles(): boolean { + return this.existingFiles.length !== 0 || this.newFiles.length !== 0; + } + + get existingFiles(): AssetEditorFile[] { + return this.form.controls.existingFiles.value; + } + + get newFiles(): AssetEditorNewFile[] { + return this.form.controls.newFiles.value; + } + + handleFileInputChange(event: Event) { + const element = event.target as HTMLInputElement; + const files = element.files; + if (files && files.length > 0) { + if (Array.from(files).some((f) => f.size > 250 * 1024 * 1024)) { + this.isFileTooLarge = true; + } else { + const file: AssetEditorNewFile = { + type: this.type, + legalDocItemCode: this.type === 'Normal' ? null : 'other', + file: Array.from(files)[0], + }; + this.form.controls.newFiles.push(new FormControl(file, { nonNullable: true })); + this.form.markAsDirty(); + this.isFileTooLarge = false; + element.value = ''; + } + } + } + + deleteFile(id: number): void { + this.form.controls.filesToDelete.setValue([...this.form.controls.filesToDelete.value, id]); + this.form.controls.existingFiles.setValue( + this.form.controls.existingFiles.value.map((it) => (it.id !== id ? it : { ...it, willBeDeleted: true })) + ); + this.form.markAsDirty(); + } + + removeNewFile(index: number) { + this.form.controls.newFiles.removeAt(index); + } +} 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 1160655d..3c2dc521 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 @@ -4,14 +4,17 @@ import { AbstractControl, FormBuilder, FormControl, ValidatorFn, Validators } fr import { AssetContactEdit, AssetFile, + AssetFileType, AssetLanguageEdit, DateId, + LegalDocItemCode, LinkedAsset, StatusAssetUseCode, StatusWork, Studies, } from '@asset-sg/shared'; +import { distinctUntilChanged, map, shareReplay, startWith } from 'rxjs'; import { IdVM } from '../models'; const makeAssetEditorGeneralFormGroup = (formBuilder: FormBuilder) => @@ -31,15 +34,39 @@ const makeAssetEditorGeneralFormGroup = (formBuilder: FormBuilder) => assetLanguages: new FormControl([], { nonNullable: true }), manCatLabelRefs: new FormControl(['other'], { nonNullable: true }), ids: new FormControl([], { nonNullable: true }), - 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; +const makeAssetEditorFilesFormGroup = (formBuilder: FormBuilder) => + formBuilder.group({ + normalFiles: makeAssetEditorFileTypeFormGroup(formBuilder), + legalFiles: makeAssetEditorFileTypeFormGroup(formBuilder), + }); + +const makeAssetEditorFileTypeFormGroup = (formBuilder: FormBuilder) => + formBuilder.group({ + existingFiles: new FormControl([], { nonNullable: true }), + filesToDelete: new FormControl([], { nonNullable: true }), + newFiles: formBuilder.array>([]), + }); + +export type AssetEditorFilesFormGroup = ReturnType; +export type AssetEditorFileTypeFormGroup = ReturnType; + +export interface AssetEditorFile extends AssetFile { + willBeDeleted: boolean; +} + +export interface AssetEditorNewFile { + type: AssetFileType; + legalDocItemCode: LegalDocItemCode | null; + file: File; +} + const validateUsageDates: ValidatorFn = (control: AbstractControl) => { const formGroup = control as AssetEditorUsageFormGroup; @@ -133,9 +160,9 @@ export type AssetEditorAdministrationFormGroup = ReturnType { const formBuilder = inject(FormBuilder); - return formBuilder.group({ general: makeAssetEditorGeneralFormGroup(formBuilder), + files: makeAssetEditorFilesFormGroup(formBuilder), usage: makeAssetEditorUsageFormGroup(formBuilder), contacts: makeAssetEditorContactsFormGroup(formBuilder), references: makeAssetEditorReferencesFormGroup(formBuilder), @@ -145,3 +172,11 @@ export const makeAssetEditorFormGroup = () => { }; export type AssetEditorFormGroup = ReturnType; + +export const isAssetEditorFormDisabled$ = (root: AssetEditorFormGroup) => + root.statusChanges.pipe( + startWith(root.status), + map((status) => status === 'DISABLED'), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }) + ); diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.html b/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.html index de0ee71a..a8820fa5 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.html +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.html @@ -33,7 +33,7 @@ (click)="showContact(assetContact.contactId)" [attr.aria-label]="'edit.tabs.contacts.viewDetails' | translate" [disabled]=" - (_disableAll$ | push) || + (disableAll$ | push) || (uiMode !== 'view' && uiMode !== 'viewContactDetails') || (currentContactId._tag === 'Some' && currentContactId.value === assetContact.contactId) " @@ -44,7 +44,7 @@ asset-sg-icon-button (click)="unlinkContact(assetContact.contactId)" [attr.aria-label]="'edit.tabs.contacts.unlinkContact' | translate" - [disabled]="(_disableAll$ | push) || (uiMode !== 'view' && uiMode !== 'viewContactDetails')" + [disabled]="(disableAll$ | push) || (uiMode !== 'view' && uiMode !== 'viewContactDetails')" > @@ -57,7 +57,7 @@ class="shrink-0 self-start" (click)="linkContact()" translate - [disabled]="(_disableAll$ | push) || (uiMode !== 'view' && uiMode !== 'viewContactDetails')" + [disabled]="(disableAll$ | push) || (uiMode !== 'view' && uiMode !== 'viewContactDetails')" > edit.tabs.contacts.linkContact diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.ts b/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.ts index ae72695e..2efc8506 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-contacts/asset-editor-tab-contacts.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnInit, Output } from '@angular/core'; import { FormBuilder, FormControl, FormGroupDirective, Validators } from '@angular/forms'; import { fromAppShared } from '@asset-sg/client-shared'; import { ordStringLowerCase } from '@asset-sg/core'; @@ -7,8 +7,8 @@ import { AssetContactRole, Contact, ContactEdit, - PatchContact, eqAssetContactEdit, + PatchContact, } from '@asset-sg/shared'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { RxState } from '@rx-angular/state'; @@ -19,20 +19,13 @@ import { Ord as ordNumber } from 'fp-ts/number'; import * as O from 'fp-ts/Option'; import { contramap } from 'fp-ts/Ord'; import * as R from 'fp-ts/Record'; +import { distinctUntilChanged, EMPTY, identity, map, Observable, skip, switchMap, take } from 'rxjs'; + import { - EMPTY, - Observable, - distinctUntilChanged, - identity, - map, - shareReplay, - skip, - startWith, - switchMap, - take, -} from 'rxjs'; - -import { AssetEditorContactsFormGroup, AssetEditorFormGroup } from '../asset-editor-form-group'; + AssetEditorContactsFormGroup, + AssetEditorFormGroup, + isAssetEditorFormDisabled$, +} from '../asset-editor-form-group'; type UIMode = 'view' | 'linkExisting' | 'linkNew' | 'viewContactDetails' | 'editContactDetails'; interface TabContactsState { @@ -81,12 +74,7 @@ export class AssetEditorTabContactsComponent implements OnInit { contactId: new FormControl(null, Validators.required), }); - public _disableAll$ = this.rootFormGroup.statusChanges.pipe( - startWith(this.rootFormGroup.status), - map(() => this.rootFormGroup.status === 'DISABLED'), - distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: true }) - ); + public readonly disableAll$ = isAssetEditorFormDisabled$(this.rootFormGroup); private _contactFormCommon = () => ({ name: new FormControl('', { nonNullable: true, validators: Validators.required }), diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.html b/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.html new file mode 100644 index 00000000..2da11408 --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.html @@ -0,0 +1,4 @@ +
+
+
+
diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.scss b/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.scss new file mode 100644 index 00000000..aacbc57b --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.scss @@ -0,0 +1,11 @@ +@use "../../styles/variables"; +@use "../../styles/mixins"; + +:host { + display: block; +} + +form { + display: flex; + max-height: 100%; +} diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.ts b/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.ts new file mode 100644 index 00000000..cbabf0c8 --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.ts @@ -0,0 +1,30 @@ +import { Component, inject, Input, OnInit } from '@angular/core'; +import { FormGroupDirective } from '@angular/forms'; +import { fromAppShared } from '@asset-sg/client-shared'; +import { Observable } from 'rxjs'; +import { + AssetEditorFilesFormGroup, + AssetEditorFormGroup, + isAssetEditorFormDisabled$, +} from '../asset-editor-form-group'; + +@Component({ + selector: 'asset-sg-editor-tab-files', + templateUrl: './asset-editor-tab-files.component.html', + styleUrl: './asset-editor-tab-files.component.scss', +}) +export class AssetEditorTabFilesComponent implements OnInit { + @Input({ required: true }) + referenceDataVM$!: Observable; + + private readonly formGroupDirective = inject(FormGroupDirective); + private readonly rootFormGroup = this.formGroupDirective.control as AssetEditorFormGroup; + public form!: AssetEditorFilesFormGroup; + + public readonly isDisabled$ = isAssetEditorFormDisabled$(this.rootFormGroup); + + ngOnInit(): void { + // TODO check if inject in class body + this.form = this.rootFormGroup.controls.files; + } +} 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 90a79a54..8fe4db56 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 @@ -1,5 +1,5 @@ -
+
workgroup.title
@@ -16,16 +16,16 @@ edit.tabs.general.publicTitle - required + required - + edit.tabs.general.sgsId edit.tabs.general.originalTitle - required + required
edit.tabs.general.date
@@ -41,7 +41,7 @@ - required + required edit.tabs.general.dateReceived @@ -55,7 +55,7 @@ - required + required
edit.tabs.general.type
@@ -99,10 +99,10 @@ pluralLabel="{{ 'edit.tabs.general.topics' | translate }}" [items]="vm.manCatLabelItems" > - +
edit.tabs.general.alternativeId
-
    - +
      +
    • @@ -130,10 +130,10 @@
    - + edit.tabs.general.alternativeId - + required @@ -141,7 +141,7 @@ required -
    +
    @@ -149,7 +149,7 @@
-
-
- -
edit.tabs.general.files
-
-
    -
  • -
    {{ file.fileName }}
    - - - - - -
    - edit.tabs.general.willBeDeleted -
    -
  • -
  • -
    {{ file.name }}
    - -
    edit.tabs.general.willBeUploaded
    -
  • -
-
-
-
edit.tabs.general.addNewFile
- -
-
diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.scss b/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.scss index 8be01065..ca5d42cb 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.scss +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-general/asset-editor-tab-general.component.scss @@ -13,22 +13,3 @@ textarea { resize: none; } - -.file-grid { - display: grid; - grid-template-areas: "status delete" "name delete"; - &.no-status { - align-items: center; - grid-template-areas: "name delete"; - } - grid-template-columns: auto 3rem; - :nth-child(1) { - grid-area: name; - } - :nth-child(2) { - grid-area: delete; - } - :nth-child(3) { - grid-area: status; - } -} 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 c5f605af..36b58cd1 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 @@ -1,5 +1,5 @@ import { FocusMonitor } from '@angular/cdk/a11y'; -import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit, ViewChild, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, inject, Input, OnInit, ViewChild } from '@angular/core'; import { FormBuilder, FormControl, FormGroupDirective } from '@angular/forms'; import { fromAppShared } from '@asset-sg/client-shared'; import { eqAssetLanguageEdit } from '@asset-sg/shared'; @@ -8,20 +8,14 @@ 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 { - BehaviorSubject, - Observable, - ReplaySubject, - combineLatest, - distinctUntilChanged, - map, - shareReplay, - startWith, - switchMap, -} from 'rxjs'; +import { combineLatest, map, Observable, ReplaySubject, shareReplay, startWith, switchMap } from 'rxjs'; import { eqIdVM } from '../../models'; -import { AssetEditorFormGroup, AssetEditorGeneralFormGroup } from '../asset-editor-form-group'; +import { + AssetEditorFormGroup, + AssetEditorGeneralFormGroup, + isAssetEditorFormDisabled$, +} from '../asset-editor-form-group'; interface AssetEditorTabGeneralState { referenceDataVM: fromAppShared.ReferenceDataVM; @@ -46,59 +40,60 @@ const initialAssetEditorTabGeneralState: AssetEditorTabGeneralState = { providers: [RxState], }) export class AssetEditorTabGeneralComponent implements OnInit { - private _rootFormGroupDirective = inject(FormGroupDirective); - private rootFormGroup = this._rootFormGroupDirective.control as AssetEditorFormGroup; - private _formBuilder = inject(FormBuilder); - private _focusMonitor = inject(FocusMonitor); + private readonly rootFormGroupDirective = inject(FormGroupDirective); + private readonly rootFormGroup = this.rootFormGroupDirective.control as AssetEditorFormGroup; + private readonly formBuilder = inject(FormBuilder); + private readonly focusMonitor = inject(FocusMonitor); - @ViewChild('idFormDescription') private _idFormDescription?: ElementRef; + @ViewChild('idFormDescription') + private readonly idFormDescription?: ElementRef; - public _form!: AssetEditorGeneralFormGroup; + public form!: AssetEditorGeneralFormGroup; - public idForm = this._formBuilder.group({ + public idForm = this.formBuilder.group({ idId: new FormControl>(O.none, { nonNullable: true }), id: new FormControl('', { nonNullable: true, updateOn: 'blur' }), description: new FormControl('', { nonNullable: true }), }); - public _state: RxState = inject(RxState); + public readonly state: RxState = inject(RxState); - public _referenceDataVM$ = this._state.select('referenceDataVM'); + public readonly _referenceDataVM$ = this.state.select('referenceDataVM'); - private _ngOnInit$ = new ReplaySubject(1); - private idsLength$ = this._ngOnInit$.pipe( + private readonly ngOnInit$ = new ReplaySubject(1); + private readonly idsLength$ = this.ngOnInit$.pipe( switchMap(() => - this._form.valueChanges.pipe( + this.form.valueChanges.pipe( startWith(null), - map(() => this._form.controls['ids'].value.length) + map(() => this.form.controls['ids'].value.length) ) ), startWith(0), shareReplay({ bufferSize: 1, refCount: true }) ); - public _userInsertMode$ = this._state.select('userInsertMode'); - public _notMoreThanOneId$ = this.idsLength$.pipe( + public readonly userInsertMode$ = this.state.select('userInsertMode'); + public readonly notMoreThanOneId$ = this.idsLength$.pipe( map((length) => length <= 1), shareReplay({ bufferSize: 1, refCount: true }) ); - public _showIdForm$ = combineLatest([this._userInsertMode$, this._notMoreThanOneId$]).pipe( + public readonly showIdForm$ = combineLatest([this.userInsertMode$, this.notMoreThanOneId$]).pipe( map(([userInsertMode, notMoreThanOneId]) => userInsertMode || notMoreThanOneId), shareReplay({ bufferSize: 1, refCount: true }) ); - public _showList$ = combineLatest([this._userInsertMode$, this.idsLength$]).pipe( + public readonly showList$ = combineLatest([this.userInsertMode$, this.idsLength$]).pipe( map(([userInsertMode, idsLength]) => userInsertMode || idsLength > 1) ); - public _idFormCompleteAndValid$ = this.idForm.valueChanges.pipe( + public readonly idFormCompleteAndValid$ = this.idForm.valueChanges.pipe( startWith(null), map(() => this.idForm.controls['id'].value && this.idForm.valid) ); - public _showCreateNewIdButton$ = combineLatest([ - this._idFormCompleteAndValid$, - this._userInsertMode$, - this._showList$, + public readonly showCreateNewIdButton$ = combineLatest([ + this.idFormCompleteAndValid$, + this.userInsertMode$, + this.showList$, ]).pipe( map( ([idFormCompleteAndValid, userInsertMode, showList]) => @@ -106,158 +101,126 @@ export class AssetEditorTabGeneralComponent implements OnInit { ), shareReplay({ bufferSize: 1, refCount: true }) ); - public _currentlyEditedIdIndex$ = this._state.select('currentlyEditedIdIndex'); + public currentlyEditedIdIndex$ = this.state.select('currentlyEditedIdIndex'); - private store = inject(Store); + private readonly store = inject(Store); /** * The workgroups to which the user is allowed to assign an asset. */ - public availableWorkgroups$ = this.store + public readonly 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); + this.state.connect('referenceDataVM', value); } constructor() { - this._state.set(initialAssetEditorTabGeneralState); + this.state.set(initialAssetEditorTabGeneralState); this.idForm.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { - if (!this._state.get().userInsertMode && !this.idForm.controls['id'].value) { + if (!this.state.get().userInsertMode && !this.idForm.controls['id'].value) { this.idForm.controls['description'].reset(undefined, { emitEvent: false }); this.idForm.controls['description'].disable({ emitEvent: false }); } else if (this.idForm.controls['description'].disabled) { this.idForm.controls['description'].enable({ emitEvent: false }); this.idForm.controls['description'].markAsTouched(); - if (this._idFormDescription) { - this._focusMonitor.focusVia(this._idFormDescription?.nativeElement, 'program'); + if (this.idFormDescription) { + this.focusMonitor.focusVia(this.idFormDescription?.nativeElement, 'program'); } } // sync the idForm state with the ids control in the general form - if (this._state.get().currentlyEditedIdIndex !== -1) { - const ids = [...this._form.controls['ids'].value]; + if (this.state.get().currentlyEditedIdIndex !== -1) { + const ids = [...this.form.controls['ids'].value]; if (this.idForm.valid) { if (ids.length === 0) { - this._form.controls['ids'].setValue([...this._form.controls['ids'].value, this.idForm.getRawValue()]); - this._form.controls['ids'].markAsDirty(); - } else if (!eqIdVM.equals(ids[this._state.get().currentlyEditedIdIndex], this.idForm.getRawValue())) { - ids[this._state.get().currentlyEditedIdIndex] = this.idForm.getRawValue(); - this._form.controls['ids'].setValue(ids, { emitEvent: false }); - this._form.markAsDirty(); + this.form.controls['ids'].setValue([...this.form.controls['ids'].value, this.idForm.getRawValue()]); + this.form.controls['ids'].markAsDirty(); + } else if (!eqIdVM.equals(ids[this.state.get().currentlyEditedIdIndex], this.idForm.getRawValue())) { + ids[this.state.get().currentlyEditedIdIndex] = this.idForm.getRawValue(); + this.form.controls['ids'].setValue(ids, { emitEvent: false }); + this.form.markAsDirty(); } } else if (ids.length > 0) { - this._form.controls.ids.setValue( - ids.filter((_, i) => i !== this._state.get().currentlyEditedIdIndex), + this.form.controls.ids.setValue( + ids.filter((_, i) => i !== this.state.get().currentlyEditedIdIndex), { emitEvent: false } ); - this._form.markAsDirty(); + this.form.markAsDirty(); } } }); } - public _disableAll$ = this.rootFormGroup.statusChanges.pipe( - startWith(this.rootFormGroup.status), - map((status) => status === 'DISABLED'), - distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: true }) - ); + public readonly isDisabled$ = isAssetEditorFormDisabled$(this.rootFormGroup); ngOnInit(): void { - this._form = this.rootFormGroup.get('general') as AssetEditorGeneralFormGroup; - this._disableAll$.pipe(untilDestroyed(this)).subscribe((disabled) => { + this.form = this.rootFormGroup.get('general') as AssetEditorGeneralFormGroup; + this.isDisabled$.pipe(untilDestroyed(this)).subscribe((disabled) => { if (disabled) { this.idForm.disable(); } else { this.idForm.enable(); } }); - this._form.valueChanges.pipe(startWith(null), untilDestroyed(this)).subscribe(() => { - if (this._form.controls['ids'].value.length === 0) { - this._state.set({ currentlyEditedIdIndex: 0 }); - } else if (this._form.controls['ids'].value.length === 1) { - this._state.set({ currentlyEditedIdIndex: 0 }); - const { idId, id, description } = this._form.controls['ids'].value[0]; + this.form.valueChanges.pipe(startWith(null), untilDestroyed(this)).subscribe(() => { + if (this.form.controls['ids'].value.length === 0) { + this.state.set({ currentlyEditedIdIndex: 0 }); + } else if (this.form.controls['ids'].value.length === 1) { + this.state.set({ currentlyEditedIdIndex: 0 }); + const { idId, id, description } = this.form.controls['ids'].value[0]; this.idForm.patchValue({ idId, id, description }); } }); - this._ngOnInit$.next(); + this.ngOnInit$.next(); } public _insertNewIdClicked() { - this._state.set({ userInsertMode: true, currentlyEditedIdIndex: -1 }); + this.state.set({ userInsertMode: true, currentlyEditedIdIndex: -1 }); this.idForm.reset(); this.idForm.markAsUntouched(); } public _cancelIdFormClicked() { - this._state.set({ userInsertMode: false, currentlyEditedIdIndex: -1 }); + this.state.set({ userInsertMode: false, currentlyEditedIdIndex: -1 }); this.idForm.reset(); - if (this._form.controls['ids'].value.length === 1) { - this._state.set({ currentlyEditedIdIndex: 0 }); - const { id, description } = this._form.controls['ids'].value[0]; + if (this.form.controls['ids'].value.length === 1) { + this.state.set({ currentlyEditedIdIndex: 0 }); + const { id, description } = this.form.controls['ids'].value[0]; this.idForm.patchValue({ id, description }); } } public _saveIdFormClicked() { - const i = this._state.get().currentlyEditedIdIndex; - this._state.set({ userInsertMode: false, currentlyEditedIdIndex: -1 }); + const i = this.state.get().currentlyEditedIdIndex; + this.state.set({ userInsertMode: false, currentlyEditedIdIndex: -1 }); if (i >= 0) { - const newIds = [...this._form.controls.ids.value]; + const newIds = [...this.form.controls.ids.value]; newIds[i] = { ...this.idForm.getRawValue(), idId: O.none }; - this._form.controls.ids.setValue(newIds); - this._form.markAsDirty(); + this.form.controls.ids.setValue(newIds); + this.form.markAsDirty(); } else { - this._form.controls.ids.setValue([ - ...this._form.controls['ids'].value, + this.form.controls.ids.setValue([ + ...this.form.controls['ids'].value, { ...this.idForm.getRawValue(), idId: O.none }, ]); - this._form.markAsDirty(); + this.form.markAsDirty(); } this.idForm.reset(); } public _deleteIdClicked(index: number) { - this._form.controls['ids'].setValue(this._form.controls['ids'].value.filter((_, i) => i !== index)); - this._form.controls['ids'].markAsDirty(); + this.form.controls['ids'].setValue(this.form.controls['ids'].value.filter((_, i) => i !== index)); + this.form.controls['ids'].markAsDirty(); } public _editIdClicked(index: number) { - this._state.set({ userInsertMode: true, currentlyEditedIdIndex: index }); - const { id, description } = this._form.controls['ids'].value[index]; + this.state.set({ userInsertMode: true, currentlyEditedIdIndex: index }); + const { id, description } = this.form.controls['ids'].value[index]; this.idForm.patchValue({ id, description }); } - public _fileInvalid$ = new BehaviorSubject(false); - public _fileInputChange(inputElement: HTMLInputElement) { - const files = inputElement.files; - if (files && files.length > 0) { - if (Array.from(files).some((f) => f.size > 250 * 1024 * 1024)) { - this._fileInvalid$.next(true); - } else { - this._form.controls.newFiles.push(new FormControl(Array.from(files)[0], { nonNullable: true })); - this._form.markAsDirty(); - this._fileInvalid$.next(false); - inputElement.value = ''; - } - } - } - - public _removeFileToBeUploaded(index: number) { - this._form.controls.newFiles.removeAt(index); - } - - public _deleteFile(fileId: number) { - this._form.controls.filesToDelete.setValue([...this._form.controls.filesToDelete.value, fileId]); - this._form.controls.assetFiles.setValue( - this._form.controls.assetFiles.value.map((f) => (f.fileId !== fileId ? f : { ...f, willBeDeleted: true })) - ); - this._form.markAsDirty(); - } - public eqAssetLanguageEdit = eqAssetLanguageEdit; } diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.html b/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.html index 02124341..79be5cec 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.html +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.html @@ -9,7 +9,7 @@ [value]="_selectedStudyId$ | push" (selectionChange)="selectStudy($event)" [placeholder]="'edit.tabs.geometries.selectGeometryLabel' | translate : { count: studies.length }" - [disabled]="(_disableAll$ | async) || mode !== 'edit-geometry'" + [disabled]="(isDisabled$ | async) || mode !== 'edit-geometry'" > {{ "Geometry" }} {{ index + 1 }} – {{ diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts b/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts index ca31b190..93ec8552 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts @@ -86,14 +86,17 @@ import { merge, Observable, share, - startWith, subscribeOn, take, takeUntil, withLatestFrom, } from 'rxjs'; -import { AssetEditorFormGroup, AssetEditorGeometriesFormGroup } from '../asset-editor-form-group'; +import { + AssetEditorFormGroup, + AssetEditorGeometriesFormGroup, + isAssetEditorFormDisabled$, +} from '../asset-editor-form-group'; type Mode = 'edit-geometry' | 'choose-new-geometry' | 'create-new-geometry'; type NewGeometryType = 'Point' | 'Polygon' | 'Linestring'; @@ -179,11 +182,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { ), }); - public _disableAll$ = this.rootFormGroup.statusChanges.pipe( - startWith(this.rootFormGroup.status), - map(() => this.rootFormGroup.status === 'DISABLED'), - distinctUntilChanged() - ); + public isDisabled$ = isAssetEditorFormDisabled$(this.rootFormGroup); public __selectedStudy$ = combineLatest([this._studies$, this._state.select('selectedStudyId')]).pipe( map(([studies, selectedStudyId]) => 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 2775c308..7a02e7d7 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 @@ -1,10 +1,13 @@ -
+
+ + + @@ -16,10 +19,10 @@ /> - + - +
+ >; @ViewChild('tabPanelGeneralContent') _tabPanelGeneralContent!: TemplateRef; + @ViewChild('tabPanelFilesContent') _tabPanelFilesContent!: TemplateRef; @ViewChild('tabPanelUsageContent') _tabPanelUsageContent!: TemplateRef; @ViewChild('tabPanelContactsContent') _tabPanelContactsContent!: TemplateRef; @ViewChild('tabPanelReferencesContent') _tabPanelReferencesContent!: TemplateRef; @@ -67,12 +69,12 @@ export class AssetEditorTabPageComponent { ); public assetEditDetail$ = this._store.select(fromAssetEditor.selectRDAssetEditDetail).pipe(ORD.fromFilteredSuccess); - public _form = makeAssetEditorFormGroup(); + public form = makeAssetEditorFormGroup(); constructor() { this._tabPageBridgeService.registerTabPage(this); - this._form.disable(); + this.form.disable(); this._store .select(fromAssetEditor.selectRDAssetEditDetail) @@ -89,7 +91,19 @@ export class AssetEditorTabPageComponent { if (this._location.path().match(/(\w+)/g)?.[3] === 'new') { this._location.replaceState(this._location.path().replace(/new/, String(asset.assetId))); } - this._form.patchValue({ + + const filesByType: Record = { + Normal: [], + Legal: [], + }; + for (const file of asset.assetFiles) { + filesByType[file.type].push({ + ...file, + willBeDeleted: false, + }); + } + + this.form.patchValue({ general: { id: asset.assetId, titlePublic: asset.titlePublic, @@ -102,11 +116,20 @@ export class AssetEditorTabPageComponent { assetLanguages: asset.assetLanguages, manCatLabelRefs: asset.manCatLabelRefs, ids: asset.ids, - filesToDelete: [], - newFiles: [], - assetFiles: asset.assetFiles.map((file) => ({ ...file, willBeDeleted: false })), workgroupId: asset.workgroupId, }, + files: { + normalFiles: { + newFiles: [], + filesToDelete: [], + existingFiles: filesByType.Normal, + }, + legalFiles: { + newFiles: [], + filesToDelete: [], + existingFiles: filesByType.Legal, + }, + }, usage: { publicUse: asset.publicUse.isAvailable, publicUseStatusAssetUseCode: asset.publicUse.statusAssetUseItemCode, @@ -140,12 +163,13 @@ export class AssetEditorTabPageComponent { }, }); } - this._form.controls.general.controls.newFiles.clear(); - this._form.enable(); - this._form.markAsUntouched(); - this._form.markAsPristine(); + this.form.controls.files.controls.normalFiles.controls.newFiles.clear(); + this.form.controls.files.controls.legalFiles.controls.newFiles.clear(); + this.form.enable(); + this.form.markAsUntouched(); + this.form.markAsPristine(); if (O.isNone(maybeAsset)) { - this._form.controls.administration.controls.newStatusWorkItemCode.disable(); + this.form.controls.administration.controls.newStatusWorkItemCode.disable(); } }); @@ -172,6 +196,11 @@ export class AssetEditorTabPageComponent { buttonLabel: createButtonLabelTranslation('edit.tabs.general.tabName'), content: this._tabPanelGeneralContent, }, + { + key: 'files', + buttonLabel: createButtonLabelTranslation('edit.tabs.files.tabName'), + content: this._tabPanelFilesContent, + }, { key: 'usage', buttonLabel: createButtonLabelTranslation('edit.tabs.usage.tabName'), @@ -238,66 +267,75 @@ export class AssetEditorTabPageComponent { }); } - save() { - if (this._form.valid) { - this._form.disable(); - const patchAsset: PatchAsset = { - titlePublic: this._form.getRawValue().general.titlePublic, - titleOriginal: this._form.getRawValue().general.titleOriginal, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - createDate: this._form.getRawValue().general.createDate!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - receiptDate: this._form.getRawValue().general.receiptDate!, - publicUse: { - isAvailable: this._form.getRawValue().usage.publicUse, - startAvailabilityDate: O.fromNullable(this._form.getRawValue().usage.publicStartAvailabilityDate), - statusAssetUseItemCode: this._form.getRawValue().usage.publicUseStatusAssetUseCode, - }, - internalUse: { - isAvailable: this._form.getRawValue().usage.internalUse, - startAvailabilityDate: O.fromNullable(this._form.getRawValue().usage.internalStartAvailabilityDate), - statusAssetUseItemCode: this._form.getRawValue().usage.internalUseStatusAssetUseCode, - }, - assetKindItemCode: this._form.getRawValue().general.assetKindItemCode, - assetFormatItemCode: this._form.getRawValue().general.assetFormatItemCode, - isNatRel: this._form.getRawValue().usage.isNatRel, - typeNatRels: this._form.getRawValue().usage.natRelTypeItemCodes, - manCatLabelRefs: this._form.getRawValue().general.manCatLabelRefs, - assetLanguages: this._form.getRawValue().general.assetLanguages, - assetContacts: this._form.getRawValue().contacts.assetContacts, - ids: this._form.getRawValue().general.ids, - studies: pipe( - this._form.getRawValue().geometries.studies, - A.filter((study) => study.studyId.match('new') === null), - A.map((study) => ({ - studyId: study.studyId, - geomText: GeomFromGeomText.encode(study.geom), - })) - ), - newStudies: pipe( - this._form.getRawValue().geometries.studies, - A.filter((study) => study.studyId.match('new') !== null), - A.map((study) => GeomFromGeomText.encode(study.geom)) - ), - 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) { - this._store.dispatch(actions.createNewAsset({ patchAsset })); - } else { - this._store.dispatch( - actions.updateAssetEditDetail({ - assetId: this._form.getRawValue().general.id, - patchAsset, - filesToDelete: this._form.getRawValue().general.filesToDelete, - newFiles: this._form.getRawValue().general.newFiles, - }) - ); - } + save(): void { + if (this.form.invalid) { + return; + } + + const { general, files, usage, contacts, geometries, references, administration } = this.form.getRawValue(); + + this.form.disable(); + + const extractAssetFiles = (group: ReturnType) => + group.existingFiles.filter((file) => !group.filesToDelete.includes(file.id)); + + const patchAsset: PatchAsset = { + titlePublic: general.titlePublic, + titleOriginal: general.titleOriginal, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + createDate: general.createDate!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + receiptDate: general.receiptDate!, + publicUse: { + isAvailable: usage.publicUse, + startAvailabilityDate: O.fromNullable(usage.publicStartAvailabilityDate), + statusAssetUseItemCode: usage.publicUseStatusAssetUseCode, + }, + internalUse: { + isAvailable: usage.internalUse, + startAvailabilityDate: O.fromNullable(usage.internalStartAvailabilityDate), + statusAssetUseItemCode: usage.internalUseStatusAssetUseCode, + }, + assetKindItemCode: general.assetKindItemCode, + assetFormatItemCode: general.assetFormatItemCode, + isNatRel: usage.isNatRel, + typeNatRels: usage.natRelTypeItemCodes, + manCatLabelRefs: general.manCatLabelRefs, + assetLanguages: general.assetLanguages, + assetContacts: contacts.assetContacts, + assetFiles: [...extractAssetFiles(files.normalFiles), ...extractAssetFiles(files.legalFiles)], + ids: general.ids, + studies: pipe( + geometries.studies, + A.filter((study) => study.studyId.match('new') === null), + A.map((study) => ({ + studyId: study.studyId, + geomText: GeomFromGeomText.encode(study.geom), + })) + ), + newStudies: pipe( + geometries.studies, + A.filter((study) => study.studyId.match('new') !== null), + A.map((study) => GeomFromGeomText.encode(study.geom)) + ), + newStatusWorkItemCode: O.fromNullable(administration.newStatusWorkItemCode), + assetMainId: O.fromNullable(references.assetMain?.assetId), + siblingAssetIds: references.siblingAssets.map((asset) => asset.assetId), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + workgroupId: general.workgroupId!, + }; + this._showProgressBar$.next(true); + if (general.id === 0) { + this._store.dispatch(actions.createNewAsset({ patchAsset })); + } else { + this._store.dispatch( + actions.updateAssetEditDetail({ + assetId: general.id, + patchAsset, + filesToDelete: [...files.normalFiles.filesToDelete, ...files.legalFiles.filesToDelete], + newFiles: [...files.normalFiles.newFiles, ...files.legalFiles.newFiles], + }) + ); } } @@ -310,7 +348,7 @@ export class AssetEditorTabPageComponent { } public canLeave(): Observable { - if (this._form.pristine) return of(true); + if (this.form.pristine) return of(true); const dialogRef = (this._discardDialogRef = this._dialogService.open(this._tmplDiscardDialog, { disableClose: true, })); 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 66d7150e..7b6ed8db 100644 --- a/libs/asset-editor/src/lib/services/asset-editor.service.ts +++ b/libs/asset-editor/src/lib/services/asset-editor.service.ts @@ -8,14 +8,15 @@ import * as E from 'fp-ts/Either'; import { flow } from 'fp-ts/function'; import { concat, forkJoin, map, of, startWith, toArray } from 'rxjs'; +import { AssetEditorNewFile } from '../components/asset-editor-form-group'; import { AssetEditDetail } from '../models'; @Injectable({ providedIn: 'root' }) export class AssetEditorService { - private _httpClient = inject(HttpClient); + private readonly httpClient = inject(HttpClient); public loadAssetDetailData(assetId: number): ORD.ObservableRemoteData { - return this._httpClient + return this.httpClient .get(`/api/asset-edit/${assetId}`) .pipe( map(flow(AssetEditDetail.decode, E.mapLeft(decodeError))), @@ -26,7 +27,7 @@ export class AssetEditorService { } public createAsset(patchAsset: PatchAsset): ORD.ObservableRemoteData { - return this._httpClient + return this.httpClient .post(`/api/asset-edit`, PatchAsset.encode(patchAsset)) .pipe( map(flow(AssetEditDetail.decode, E.mapLeft(decodeError))), @@ -40,7 +41,7 @@ export class AssetEditorService { assetId: number, patchAsset: PatchAsset ): ORD.ObservableRemoteData { - return this._httpClient + return this.httpClient .put(`/api/asset-edit/${assetId}`, PatchAsset.encode(patchAsset)) .pipe( map(flow(AssetEditDetail.decode, E.mapLeft(decodeError))), @@ -54,8 +55,8 @@ export class AssetEditorService { return fileIds.length ? forkJoin( fileIds.map((fileId) => { - return this._httpClient - .delete(`/api/files/${fileId}`) + return this.httpClient + .delete(`/api/assets/${assetId}/files/${fileId}`) .pipe(map(E.right), OE.catchErrorW(httpErrorResponseError), map(RD.fromEither), startWith(RD.pending)); }) ).pipe( @@ -70,15 +71,18 @@ export class AssetEditorService { : of(RD.success(undefined)); } - public uploadFiles(assetId: number, files: File[]): ORD.ObservableRemoteData { + public uploadFiles(assetId: number, files: AssetEditorNewFile[]): ORD.ObservableRemoteData { return files.length ? concat( ...files.map((file) => { const formData = new FormData(); - formData.append('file', file); - formData.append('assetId', `${assetId}`); - return this._httpClient - .post(`/api/files`, formData) + formData.append('file', file.file); + formData.append('type', file.type); + if (file.legalDocItemCode != null) { + formData.append('legalDocItemCode', file.legalDocItemCode); + } + return this.httpClient + .post(`/api/assets/${assetId}/files`, formData) .pipe(map(E.right), OE.catchErrorW(httpErrorResponseError)); }) ).pipe( @@ -96,7 +100,7 @@ export class AssetEditorService { } public updateContact(contactId: number, patchContact: PatchContact): ORD.ObservableRemoteData { - return this._httpClient + return this.httpClient .put(`/api/contacts/${contactId}`, PatchContact.encode(patchContact)) .pipe( map(flow(Contact.decode, E.mapLeft(decodeError))), @@ -107,7 +111,7 @@ export class AssetEditorService { } public createContact(patchContact: PatchContact): ORD.ObservableRemoteData { - return this._httpClient + return this.httpClient .post(`/api/contacts`, PatchContact.encode(patchContact)) .pipe( map(flow(Contact.decode, E.mapLeft(decodeError))), diff --git a/libs/asset-editor/src/lib/state/asset-editor.actions.ts b/libs/asset-editor/src/lib/state/asset-editor.actions.ts index 018e7be8..00d9eab2 100644 --- a/libs/asset-editor/src/lib/state/asset-editor.actions.ts +++ b/libs/asset-editor/src/lib/state/asset-editor.actions.ts @@ -4,6 +4,7 @@ import * as RD from '@devexperts/remote-data-ts'; import { createAction, props } from '@ngrx/store'; import * as O from 'fp-ts/Option'; +import { AssetEditorNewFile } from '../components/asset-editor-form-group'; import { AssetEditDetail } from '../models'; export const loadAssetEditDetailResult = createAction( @@ -15,7 +16,7 @@ export const createNewAsset = createAction('[Asset Editor] Create new asset', pr export const updateAssetEditDetail = createAction( '[Asset Editor] Update asset', - props<{ assetId: number; patchAsset: PatchAsset; filesToDelete: number[]; newFiles: File[] }>() + props<{ assetId: number; patchAsset: PatchAsset; filesToDelete: number[]; newFiles: AssetEditorNewFile[] }>() ); export const updateAssetEditDetailResult = createAction( diff --git a/libs/asset-viewer/src/lib/asset-viewer.module.ts b/libs/asset-viewer/src/lib/asset-viewer.module.ts index 4007254e..a215d0cf 100644 --- a/libs/asset-viewer/src/lib/asset-viewer.module.ts +++ b/libs/asset-viewer/src/lib/asset-viewer.module.ts @@ -54,6 +54,7 @@ import { AssetSearchDetailComponent } from './components/asset-search-detail'; import { AssetSearchFilterListComponent } from './components/asset-search-filter-list/asset-search-filter-list.component'; import { AssetSearchRefineComponent } from './components/asset-search-refine'; import { AssetSearchResultsComponent } from './components/asset-search-results'; +import { AssetViewerFilesComponent } from './components/asset-viewer-files/asset-viewer-files.component'; import { AssetViewerPageComponent } from './components/asset-viewer-page'; import { MapComponent } from './components/map/map.component'; import { MapControlsComponent } from './components/map-controls/map-controls.component'; @@ -70,6 +71,7 @@ import { mapControlReducer } from './state/map-control/map-control.reducer'; AssetSearchRefineComponent, AssetSearchFilterListComponent, AssetSearchResultsComponent, + AssetViewerFilesComponent, AssetPickerComponent, ], imports: [ 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 2d3cf89c..966584d4 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 @@ -52,35 +52,11 @@ - - - +
+ + @@ -116,6 +92,12 @@ + + + +
-
    - -
+
    +
      +
      diff --git a/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.scss b/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.scss index 61e110f8..bc731e2b 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.scss +++ b/libs/asset-viewer/src/lib/components/asset-search-detail/asset-search-detail.component.scss @@ -52,11 +52,14 @@ table.asset-details { table-layout: fixed; - border-collapse: collapse; width: 100%; min-width: 100%; max-width: 100%; + // Add space between each of the table's rows. + border-collapse: separate; + border-spacing: 0 6px; + tr:first-child { th:nth-child(1) { $width: 10rem; @@ -103,7 +106,7 @@ td .line { table.status-works { table-layout: fixed; border-collapse: separate; - border-spacing: 0rem 0.125rem; + border-spacing: 0 0.125rem; width: 100%; min-width: 100%; max-width: 100%; @@ -150,13 +153,14 @@ li::marker { li.link { display: inline-flex; - align-items: center; - max-width: 100%; + width: 100%; overflow: visible; + align-items: center; + flex-wrap: wrap; + column-gap: 6px; - .filename { - width: calc(100% - 5.5rem); - word-wrap: break-word; + &:not(:first-child) { + margin-top: 6px; } a { @@ -166,10 +170,6 @@ li.link { a:first-of-type { margin-left: 0.5rem; } - - mat-progress-spinner { - margin: calc(calc(2.5rem - 24px) / 2); - } } // A list of languages to which an asset is mapped. 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 53490aac..95f64f77 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 @@ -1,13 +1,13 @@ -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 { AssetFileType } from '@asset-sg/shared'; import { AssetEditPolicy } from '@asset-sg/shared/v2'; import { Store } from '@ngrx/store'; - +import { map, Observable } from 'rxjs'; import * as actions from '../../state/asset-search/asset-search.actions'; import { LoadingState } from '../../state/asset-search/asset-search.reducer'; import { + AssetDetailFileVM, selectAssetDetailLoadingState, selectCurrentAssetDetailVM, } from '../../state/asset-search/asset-search.selector'; @@ -18,56 +18,35 @@ import { styleUrls: ['./asset-search-detail.component.scss'], }) export class AssetSearchDetailComponent { - private _store = inject(Store); - public readonly assetDetail$ = this._store.select(selectCurrentAssetDetailVM); - public loadingState = this._store.select(selectAssetDetailLoadingState); - - public readonly activeFileDownloads = new Map(); + private readonly store = inject(Store); + + public readonly assetDetail$ = this.store.select(selectCurrentAssetDetailVM); + public readonly filesByType$: Observable> = this.assetDetail$.pipe( + map((asset) => { + const mapping: Record = { + Normal: [], + Legal: [], + }; + if (asset == null) { + return mapping; + } + for (const file of asset.assetFiles) { + mapping[file.type].push(file); + } + return mapping; + }) + ); + + public loadingState = this.store.select(selectAssetDetailLoadingState); public resetAssetDetail() { - this._store.dispatch(actions.resetAssetDetail()); - } - - protected readonly LoadingState = LoadingState; - - constructor(private httpClient: HttpClient) {} - - public isActiveFileDownload(file: Omit, isDownload = true): boolean { - const download = this.activeFileDownloads.get(file.fileId); - return download != null && download.isDownload == isDownload; + this.store.dispatch(actions.resetAssetDetail()); } public searchForReferenceAsset(assetId: number) { - this._store.dispatch(actions.assetClicked({ assetId })); - } - - public downloadFile(file: Omit, isDownload = true): void { - this.activeFileDownloads.set(file.fileId, { isDownload }); - this.httpClient.get(`/api/files/${file.fileId}`, { responseType: 'blob' }).subscribe({ - next: (blob) => { - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - - anchor.setAttribute('style', 'display: none'); - anchor.href = url; - anchor.rel = 'noopener noreferrer'; - if (isDownload) { - anchor.download = file.fileName; - } else { - anchor.target = '_blank'; - } - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => { - window.URL.revokeObjectURL(url); - }); - }, - complete: () => { - this.activeFileDownloads.delete(file.fileId); - }, - }); + this.store.dispatch(actions.assetClicked({ assetId })); } + protected readonly LoadingState = LoadingState; protected readonly AssetEditPolicy = AssetEditPolicy; } diff --git a/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.html b/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.html new file mode 100644 index 00000000..33bcb74d --- /dev/null +++ b/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.html @@ -0,0 +1,26 @@ + diff --git a/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.scss b/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.scss new file mode 100644 index 00000000..ddd497f9 --- /dev/null +++ b/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.scss @@ -0,0 +1,32 @@ +:host { + list-style-type: none; + display: flex; + flex-direction: column; + row-gap: 6px; +} + +:host > li { + display: flex; + width: 100%; + align-items: center; + flex-wrap: wrap; + column-gap: 4px; +} + +.legal-doc-item-code { + width: 100%; +} + +.name { + max-width: calc(100% - 5.5rem); + word-wrap: break-word; +} + +button[asset-sg-icon-button], +button[asset-sg-icon-button] ::ng-deep svg-icon { + display: flex; + align-items: center; + + width: 20px; + height: 20px; +} diff --git a/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.ts b/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.ts new file mode 100644 index 00000000..845d2f33 --- /dev/null +++ b/libs/asset-viewer/src/lib/components/asset-viewer-files/asset-viewer-files.component.ts @@ -0,0 +1,93 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { AppState, fromAppShared } from '@asset-sg/client-shared'; +import { AssetFile, AssetFileType } from '@asset-sg/shared'; +import { AssetId } from '@asset-sg/shared/v2'; +import { Store } from '@ngrx/store'; +import { Subscription } from 'rxjs'; +import { AssetDetailFileVM, displayFileSize } from '../../state/asset-search/asset-search.selector'; + +@Component({ + selector: 'ul[asset-sg-asset-viewer-files]', + templateUrl: './asset-viewer-files.component.html', + styleUrl: './asset-viewer-files.component.scss', +}) +export class AssetViewerFilesComponent implements OnInit, OnDestroy { + @Input({ required: true }) + assetId!: AssetId; + + @Input({ required: true }) + files!: AssetDetailFileVM[]; + + @Input({ required: true }) + type!: AssetFileType; + + private readonly store = inject(Store); + + private readonly httpClient = inject(HttpClient); + + public locale!: string; + + public readonly activeFileDownloads = new Set<`${AssetId}/${number}/${DownloadType}`>(); + + private readonly subscriptions = new Subscription(); + + ngOnInit(): void { + this.subscriptions.add( + this.store.select(fromAppShared.selectLocale).subscribe((locale) => { + this.locale = locale; + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + get isNormal(): boolean { + return this.type === 'Normal'; + } + + get isLegal(): boolean { + return this.type === 'Legal'; + } + + public displayFileSize(size: number): string { + return displayFileSize(size, this.locale); + } + + public isActiveFileDownload(file: Omit, downloadType: DownloadType): boolean { + return this.activeFileDownloads.has(`${this.assetId}/${file.id}/${downloadType}`); + } + + public downloadFile(file: Omit, downloadType: DownloadType): void { + const key = `${this.assetId}/${file.id}/${downloadType}` as const; + this.activeFileDownloads.add(key); + this.httpClient.get(`/api/assets/${this.assetId}/files/${file.id}`, { responseType: 'blob' }).subscribe({ + next: (blob) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.setAttribute('style', 'display: none'); + anchor.href = url; + anchor.rel = 'noopener noreferrer'; + if (downloadType === 'save-file') { + anchor.download = file.name; + } else { + anchor.target = '_blank'; + } + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => { + window.URL.revokeObjectURL(url); + }); + }, + complete: () => { + this.activeFileDownloads.delete(key); + }, + }); + } +} + +type DownloadType = 'save-file' | 'open-in-tab'; diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts index 8d856db4..581c6e63 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts @@ -14,7 +14,6 @@ import { ordStatusWorkByDate, Point, ReferenceData, - Study, StudyPolygon, UsageCode, usageCodes, @@ -34,8 +33,6 @@ import { AppStateWithAssetSearch } from './asset-search.reducer'; const assetSearchFeature = (state: AppStateWithAssetSearch) => state.assetSearch; -export const selectAssetSearchState = createSelector(assetSearchFeature, (state) => state); - export const selectSearchLoadingState = createSelector(assetSearchFeature, (state) => state.resultsLoadingState); export const selectFilterLoadingState = createSelector(assetSearchFeature, (state) => state.filterLoadingState); @@ -66,13 +63,12 @@ export const selectAssetSearchNoActiveFilters = createSelector(assetSearchFeatur export const selectCurrentAssetDetailVM = createSelector( fromAppShared.selectRDReferenceData, - fromAppShared.selectLocale, selectCurrentAssetDetail, - (referenceData, locale, currentAssetDetail) => { + (referenceData, currentAssetDetail) => { if (RD.isSuccess(referenceData) && !!currentAssetDetail) { - return makeAssetDetailVMNew(referenceData.value, currentAssetDetail, locale); + return makeAssetDetailVMNew(referenceData.value, currentAssetDetail); } - return null as ReturnType | null; + return null as AssetDetailVM | null; } ); @@ -311,10 +307,6 @@ export const makeTranslatedValueFromItemName = (item: ValueItem): TranslatedValu en: item.nameEn, }); -export interface StudyVM extends Study { - assetId: number; -} - export interface AssetEditDetailVM { assetId: number; titlePublic: string; @@ -325,7 +317,10 @@ export interface AssetEditDetailVM { manCatLabelItems: ValueItem[]; } -const makeAssetDetailVMNew = (referenceData: ReferenceData, assetDetail: AssetEditDetail, locale: string) => { +export type AssetDetailVM = ReturnType; +export type AssetDetailFileVM = AssetDetailVM['assetFiles'][0]; + +const makeAssetDetailVMNew = (referenceData: ReferenceData, assetDetail: AssetEditDetail) => { const { assetFormatItemCode, assetKindItemCode, @@ -375,18 +370,18 @@ const makeAssetDetailVMNew = (referenceData: ReferenceData, assetDetail: AssetEd return { ...rest, statusWork: referenceData.statusWorkItems[statusWorkItemCode] }; }) ), - assetFiles: assetFiles.map((assetFile) => { - const _fileSize = assetFile.fileSize / 1024n / 1024n; - const fileSize = - _fileSize < 1 ? `< 1MB` : `${formatNumber(Number(bigIntRoundToMB(assetFile.fileSize)), locale)}MB`; - return { - ...assetFile, - fileSize, - }; - }), + assetFiles: assetFiles.map((it) => ({ + ...it, + legalDocItem: it.legalDocItemCode == null ? null : referenceData.legalDocItems[it.legalDocItemCode], + })), }; }; +export const displayFileSize = (size: number, locale: string): string => { + const _fileSize = size / 1024 / 1024; + return _fileSize < 1 ? `< 1MB` : `${formatNumber(Number(roundToMB(size)), locale)}MB`; +}; + const makeAssetDetailContactVM = (referenceData: ReferenceData, assetContact: AssetDetail['assetContacts'][0]) => { const { role, @@ -401,10 +396,10 @@ const makeAssetDetailContactVM = (referenceData: ReferenceData, assetContact: As }; }; -const bigIntRoundToMB = (value: bigint) => { - const n = value / 1024n / 1024n; - const rem = value - n * 1024n * 1024n; - return rem > 524288n ? n + 1n : n; +const roundToMB = (value: number) => { + const n = value / 1024 / 1024; + const rem = value - n * 1024 * 1024; + return rem > 524288 ? n + 1 : n; }; export function wktToGeoJSON(wkt: string) { diff --git a/libs/core/src/lib/io-ts-types/Decoder/bigint.ts b/libs/core/src/lib/io-ts-types/Decoder/bigint.ts index 516f469e..153e04cf 100644 --- a/libs/core/src/lib/io-ts-types/Decoder/bigint.ts +++ b/libs/core/src/lib/io-ts-types/Decoder/bigint.ts @@ -1,3 +1,5 @@ +import * as E from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; import * as D from 'io-ts/Decoder'; import * as G from 'io-ts/Guard'; @@ -6,3 +8,8 @@ const bigintGuard: G.Guard = { }; export const bigint: D.Decoder = D.fromGuard(bigintGuard, 'bigint'); + +export const numberFromBigint: D.Decoder = pipe( + D.fromGuard(bigintGuard, 'bigint'), + D.parse((value) => E.right(Number(value))) +); diff --git a/libs/core/src/lib/io-ts-types/Decoder/index.ts b/libs/core/src/lib/io-ts-types/Decoder/index.ts index 7cfa78b1..1e5edc71 100644 --- a/libs/core/src/lib/io-ts-types/Decoder/index.ts +++ b/libs/core/src/lib/io-ts-types/Decoder/index.ts @@ -1,5 +1,5 @@ export { date, dateGuard } from './date'; -export { bigint } from './bigint'; +export { bigint, numberFromBigint } from './bigint'; export { BigIntFromString } from './BigIntFromString'; export { BooleanFromString } from './BooleanFromString'; export { DateFromISOString } from './DateFromString'; diff --git a/libs/persistence/prisma/migrations/20241001062933_add_legal_files/migration.sql b/libs/persistence/prisma/migrations/20241001062933_add_legal_files/migration.sql new file mode 100644 index 00000000..16549a47 --- /dev/null +++ b/libs/persistence/prisma/migrations/20241001062933_add_legal_files/migration.sql @@ -0,0 +1,37 @@ +-- Cleanup workgroup migration. +ALTER TABLE "asset" DROP CONSTRAINT "asset_workgroup_id_fkey"; +ALTER TABLE "asset" ADD CONSTRAINT "asset_workgroup_id_fkey" FOREIGN KEY ("workgroup_id") REFERENCES "workgroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "asset" ALTER COLUMN "workgroup_id" SET NOT NULL; + +-- CreateEnum +CREATE TYPE "FileType" AS ENUM ('normal', 'legal'); + +-- AlterTable +ALTER TABLE "file" RENAME COLUMN "file_id" TO "id"; +ALTER TABLE "file" RENAME COLUMN "file_date" TO "last_modified_at"; +ALTER TABLE "file" RENAME COLUMN "file_name" TO "name"; +ALTER TABLE "file" RENAME COLUMN "file_size" TO "size"; + +ALTER TABLE "file" + ADD COLUMN "legal_doc_item_code" TEXT, + ADD COLUMN "type" "FileType" NOT NULL DEFAULT 'normal'; + +-- DropTable +DROP TABLE "legal_doc"; + +-- CreateIndex +CREATE INDEX "file_type_idx" ON "file"("type"); + +-- AddForeignKey +ALTER TABLE "file" ADD CONSTRAINT "file_legal_doc_item_code_fkey" FOREIGN KEY ("legal_doc_item_code") REFERENCES "legal_doc_item"("legal_doc_item_code") ON DELETE SET NULL ON UPDATE CASCADE; + +-- RenameIndex +ALTER INDEX "file_file_name_key" RENAME TO "file_name_key"; + +-- Mark legal files. +UPDATE "file" + SET + "type" = 'legal', + "legal_doc_item_code" = 'permissionForm' + WHERE + LOWER("name") LIKE '%_ldoc.pdf'; diff --git a/libs/persistence/prisma/schema.prisma b/libs/persistence/prisma/schema.prisma index 040d667b..56cf8162 100644 --- a/libs/persistence/prisma/schema.prisma +++ b/libs/persistence/prisma/schema.prisma @@ -51,7 +51,6 @@ model Asset { assetLanguages AssetLanguage[] autoCats AutoCat[] ids Id[] - legalDocs LegalDoc[] manCatLabelRefs ManCatLabelRef[] statusWorks StatusWork[] studyAreas StudyArea[] @@ -122,7 +121,7 @@ model AssetFile { assetId Int @map("asset_id") asset Asset @relation(fields: [assetId], references: [assetId]) fileId Int @map("file_id") - file File @relation(fields: [fileId], references: [fileId], onDelete: Cascade) + file File @relation(fields: [fileId], references: [id], onDelete: Cascade) @@id([assetId, fileId]) @@map("asset_file") @@ -137,24 +136,34 @@ enum OcrState { success } +enum FileType { + Normal @map("normal") + Legal @map("legal") +} + model File { - fileId Int @id @default(autoincrement()) @map("file_id") - fileName String @map("file_name") - ocrStatus OcrState @default(created) @map("ocr_status") - fileSize BigInt @map("file_size") - lastModified DateTime @map("file_date") + id Int @id @default(autoincrement()) @map("id") + name String @map("name") + ocrStatus OcrState @default(created) @map("ocr_status") + size BigInt @map("size") + lastModifiedAt DateTime @map("last_modified_at") + type FileType @default(Normal) @map("type") + + legalDocItemCode String? @map("legal_doc_item_code") + legalDocItem LegalDocItem? @relation(fields: [legalDocItemCode], references: [legalDocItemCode]) AssetObjectInfo AssetObjectInfo[] AssetFile AssetFile[] - @@unique([fileName]) + @@unique([name]) + @@index([type]) @@map("file") } model AssetObjectInfo { assetObjectInfoId Int @id @default(autoincrement()) @map("asset_object_info_id") fileId Int @map("file_id") - file File @relation(fields: [fileId], references: [fileId]) + file File @relation(fields: [fileId], references: [id]) autoObjectCatItemCode String @map("auto_object_cat_item_code") autoObjectCatItem AutoObjectCatItem @relation(fields: [autoObjectCatItemCode], references: [autoObjectCatItemCode]) objectPage String @map("object_page") @@ -228,17 +237,6 @@ model TypeNatRel { @@map("type_nat_rel") } -model LegalDoc { - legalDocId Int @id @default(autoincrement()) @map("legal_doc_id") - assetId Int @map("asset_id") - asset Asset @relation(fields: [assetId], references: [assetId]) - title String @map("title") - legalDocItemCode String @map("legal_doc_item_code") - legalDocItem LegalDocItem @relation(fields: [legalDocItemCode], references: [legalDocItemCode]) - - @@map("legal_doc") -} - model Contact { contactId Int @id @default(autoincrement()) @map("contact_id") contactKindItemCode String @map("contact_kind_item_code") @@ -567,8 +565,7 @@ model LegalDocItem { descriptionRm String @map("description_rm") descriptionIt String @map("description_it") descriptionEn String @map("description_en") - - legalDocs LegalDoc[] + File File[] @@map("legal_doc_item") } diff --git a/libs/shared/src/lib/models/asset-detail.ts b/libs/shared/src/lib/models/asset-detail.ts index 7e1f54da..cb6966db 100644 --- a/libs/shared/src/lib/models/asset-detail.ts +++ b/libs/shared/src/lib/models/asset-detail.ts @@ -30,6 +30,14 @@ export const AssetContactRole = C.fromDecoder( export type AssetContactRole = C.TypeOf; export const eqAssetContactRole: Eq = eqString; +export const AssetFileType = C.fromDecoder(D.union(D.literal('Normal'), D.literal('Legal'))); +export type AssetFileType = C.TypeOf; + +export const LegalDocItemCode = C.fromDecoder( + D.union(D.literal('federalData'), D.literal('permissionForm'), D.literal('contract'), D.literal('other')) +); +export type LegalDocItemCode = C.TypeOf; + export const BaseAssetDetail = { assetId: C.number, titlePublic: C.string, @@ -83,5 +91,13 @@ export const BaseAssetDetail = { siblingXAssets: C.array(LinkedAsset), siblingYAssets: C.array(LinkedAsset), statusWorks: C.array(StatusWork), - assetFiles: C.array(C.struct({ fileId: C.number, fileName: C.string, fileSize: CT.BigIntFromString })), + assetFiles: C.array( + C.struct({ + id: C.number, + name: C.string, + size: C.number, + type: AssetFileType, + legalDocItemCode: C.nullable(LegalDocItemCode), + }) + ), }; diff --git a/libs/shared/src/lib/models/asset-edit.ts b/libs/shared/src/lib/models/asset-edit.ts index 933888d0..2cb217c2 100644 --- a/libs/shared/src/lib/models/asset-edit.ts +++ b/libs/shared/src/lib/models/asset-edit.ts @@ -4,7 +4,14 @@ import { Eq as eqNumber } from 'fp-ts/number'; import { Eq as eqString } from 'fp-ts/string'; import * as C from 'io-ts/Codec'; -import { AssetContactRole, eqAssetContactRole, LinkedAsset, StatusWork } from './asset-detail'; +import { + AssetContactRole, + AssetFileType, + eqAssetContactRole, + LegalDocItemCode, + LinkedAsset, + StatusWork, +} from './asset-detail'; import { AssetUsage } from './asset-usage'; import { DateId } from './DateStruct'; @@ -53,7 +60,13 @@ export const eqAssetContactEdit = struct({ contactId: eqNumber, }); -export const AssetFile = C.struct({ fileId: C.number, fileName: C.string, fileSize: CT.BigIntFromString }); +export const AssetFile = C.struct({ + id: C.number, + name: C.string, + size: C.number, + type: AssetFileType, + legalDocItemCode: C.nullable(LegalDocItemCode), +}); export type AssetFile = C.TypeOf; diff --git a/libs/shared/src/lib/models/patch-asset.ts b/libs/shared/src/lib/models/patch-asset.ts index a6d43b30..e36ecb60 100644 --- a/libs/shared/src/lib/models/patch-asset.ts +++ b/libs/shared/src/lib/models/patch-asset.ts @@ -1,7 +1,7 @@ import { CT } from '@asset-sg/core'; import * as C from 'io-ts/Codec'; -import { AssetContactEdit, AssetLanguageEdit } from './asset-edit'; +import { AssetContactEdit, AssetFile, AssetLanguageEdit } from './asset-edit'; import { AssetUsage } from './asset-usage'; import { DateId } from './DateStruct'; @@ -19,6 +19,7 @@ export const PatchAsset = C.struct({ typeNatRels: C.array(C.string), assetLanguages: C.array(AssetLanguageEdit), assetContacts: C.array(AssetContactEdit), + assetFiles: C.array(AssetFile), ids: C.array( C.struct({ idId: CT.optionFromNullable(C.number),