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/e2e/cypress/e2e/common/create-test-asset.ts b/e2e/cypress/e2e/common/create-test-asset.ts index 42a6b09f..15d9b536 100644 --- a/e2e/cypress/e2e/common/create-test-asset.ts +++ b/e2e/cypress/e2e/common/create-test-asset.ts @@ -1,8 +1,38 @@ import { Given } from '@badeball/cypress-cucumber-preprocessor'; import { bearerAuth } from '../../support/commands/helper.commands'; -const body = - '{"titlePublic":"CypressTestAsset","titleOriginal":"CypressTestAsset","createDate":20240902,"receiptDate":20240912,"publicUse":{"isAvailable":false,"statusAssetUseItemCode":"tobechecked","startAvailabilityDate":null},"internalUse":{"isAvailable":true,"statusAssetUseItemCode":"tobechecked","startAvailabilityDate":null},"assetKindItemCode":"basemap","assetFormatItemCode":"unknown","isNatRel":true,"manCatLabelRefs":["other"],"typeNatRels":[],"assetLanguages":[],"assetContacts":[],"ids":[],"studies":[],"assetMainId":null,"siblingAssetIds":[],"newStudies":["POINT(2661254.953 1186121.169)"],"newStatusWorkItemCode":"initiateAsset","workgroupId":1}'; +const body = JSON.stringify({ + titlePublic: 'CypressTestAsset', + titleOriginal: 'CypressTestAsset', + createDate: 20240902, + receiptDate: 20240912, + publicUse: { + isAvailable: false, + statusAssetUseItemCode: 'tobechecked', + startAvailabilityDate: null, + }, + internalUse: { + isAvailable: true, + statusAssetUseItemCode: 'tobechecked', + startAvailabilityDate: null, + }, + assetKindItemCode: 'basemap', + assetFormatItemCode: 'unknown', + isNatRel: true, + manCatLabelRefs: ['other'], + typeNatRels: [], + assetLanguages: [], + assetContacts: [], + ids: [], + studies: [], + assetMainId: null, + siblingAssetIds: [], + newStudies: ['POINT(2661254.953 1186121.169)'], + newStatusWorkItemCode: 'initiateAsset', + assetFiles: [], + workgroupId: 1, +}); + Given('Test asset is created', () => { cy.wait(3000); cy.window() 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..b9f8030d --- /dev/null +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-files/asset-editor-tab-files.component.ts @@ -0,0 +1,29 @@ +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: AssetEditorFormGroup = this.formGroupDirective.control; + public form!: AssetEditorFilesFormGroup; + + public readonly isDisabled$ = isAssetEditorFormDisabled$(this.rootFormGroup); + + ngOnInit(): void { + 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..ee48b446 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: AssetEditorFormGroup = this.rootFormGroupDirective.control; + 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),