diff --git a/CHANGELOG.md b/CHANGELOG.md index 90963dc8..56926bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - New Use Case: [Get Collections For Linking Use Case](./docs/useCases.md#get-collections-for-linking). - New Use Case: [Create a Dataset Template](./docs/useCases.md#create-a-dataset-template) under Collections. +- New Use Case: [Update Terms of Access](./docs/useCases.md#update-terms-of-access). + ### Changed - Add pagination query parameters to Dataset Version Summeries and File Version Summaries use cases diff --git a/docs/useCases.md b/docs/useCases.md index 77b72d33..3254e4a5 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -1152,6 +1152,38 @@ The `versionUpdateType` parameter can be a [VersionUpdateType](../src/datasets/d - `VersionUpdateType.MAJOR` - `VersionUpdateType.UPDATE_CURRENT` +#### Update Terms of Access + +Updates the Terms of Access for restricted files on a dataset. + +##### Example call: + +```typescript +import { updateTermsOfAccess } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 3 + +await updateTermsOfAccess.execute(datasetId, { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'Your terms of access for restricted files', + dataAccessPlace: 'Your data access place', + originalArchive: 'Your original archive', + availabilityStatus: 'Your availability status', + contactForAccess: 'Your contact for access', + sizeOfCollection: 'Your size of collection', + studyCompletion: 'Your study completion' +}) +``` + +_See [use case](../src/datasets/domain/useCases/UpdateTermsOfAccess.ts) implementation_. + +Notes: + +- If the dataset is already published, this action creates a DRAFT version containing the new terms. +- Unspecified fields are treated as omissions: sending only `fileAccessRequest` will update that field and leave all other terms absent (undefined). In practice, the new values you send fully replace the previous set of terms — so if you omit a field, you are effectively clearing it unless you include its original value in the new input. + #### Deaccession a Dataset Deaccession a Dataset, given its identifier, version, and deaccessionDatasetDTO to perform. diff --git a/src/datasets/domain/models/Dataset.ts b/src/datasets/domain/models/Dataset.ts index f76f11f2..e1ccfb25 100644 --- a/src/datasets/domain/models/Dataset.ts +++ b/src/datasets/domain/models/Dataset.ts @@ -51,6 +51,7 @@ export interface CustomTerms { conditions?: string disclaimer?: string } + export interface TermsOfAccess { fileAccessRequest: boolean termsOfAccessForRestrictedFiles?: string diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index b40f5e45..8a52f8f9 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -14,6 +14,7 @@ import { CitationFormat } from '../models/CitationFormat' import { FormattedCitation } from '../models/FormattedCitation' import { DatasetTemplate } from '../models/DatasetTemplate' import { DatasetType } from '../models/DatasetType' +import { TermsOfAccess } from '../models/Dataset' import { DatasetLicenseUpdateRequest } from '../dtos/DatasetLicenseUpdateRequest' import { DatasetTypeDTO } from '../dtos/DatasetTypeDTO' @@ -96,6 +97,7 @@ export interface IDatasetsRepository { licenses: string[] ): Promise deleteDatasetType(datasetTypeId: number): Promise + updateTermsOfAccess(datasetId: number | string, termsOfAccess: TermsOfAccess): Promise updateDatasetLicense( datasetId: number | string, payload: DatasetLicenseUpdateRequest diff --git a/src/datasets/domain/useCases/SetAvailableLicensesForDatasetType.ts b/src/datasets/domain/useCases/SetAvailableLicensesForDatasetType.ts index e4df9ab2..9741aa08 100644 --- a/src/datasets/domain/useCases/SetAvailableLicensesForDatasetType.ts +++ b/src/datasets/domain/useCases/SetAvailableLicensesForDatasetType.ts @@ -10,6 +10,10 @@ export class SetAvailableLicensesForDatasetType implements UseCase { /** * Sets the available licenses for a given dataset type. This limits the license options when creating a dataset of this type. + * + * @param {number | string} [datasetTypeId] - The dataset type identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {string[]} licenses - The licenses to set for the dataset type. + * @returns {Promise} - This method does not return anything upon successful completion. */ async execute(datasetTypeId: number | string, licenses: string[]): Promise { return await this.datasetsRepository.setAvailableLicensesForDatasetType(datasetTypeId, licenses) diff --git a/src/datasets/domain/useCases/UpdateTermsOfAccess.ts b/src/datasets/domain/useCases/UpdateTermsOfAccess.ts new file mode 100644 index 00000000..103d4f00 --- /dev/null +++ b/src/datasets/domain/useCases/UpdateTermsOfAccess.ts @@ -0,0 +1,22 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' +import { TermsOfAccess } from '../models/Dataset' + +export class UpdateTermsOfAccess implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Sets the terms of access for a given dataset. + * + * @param {number | string} [datasetId] - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {TermsOfAccess} termsOfAccess - The terms of access to set for the dataset. + * @returns {Promise} - This method does not return anything upon successful completion. + */ + async execute(datasetId: number | string, termsOfAccess: TermsOfAccess): Promise { + return await this.datasetsRepository.updateTermsOfAccess(datasetId, termsOfAccess) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index c26e2da0..b8edb5b3 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -32,6 +32,7 @@ import { SetAvailableLicensesForDatasetType } from './domain/useCases/SetAvailab import { DeleteDatasetType } from './domain/useCases/DeleteDatasetType' import { GetDatasetCitationInOtherFormats } from './domain/useCases/GetDatasetCitationInOtherFormats' import { GetDatasetTemplates } from './domain/useCases/GetDatasetTemplates' +import { UpdateTermsOfAccess } from './domain/useCases/UpdateTermsOfAccess' import { UpdateDatasetLicense } from './domain/useCases/UpdateDatasetLicense' const datasetsRepository = new DatasetsRepository() @@ -81,6 +82,7 @@ const setAvailableLicensesForDatasetType = new SetAvailableLicensesForDatasetTyp const deleteDatasetType = new DeleteDatasetType(datasetsRepository) const getDatasetCitationInOtherFormats = new GetDatasetCitationInOtherFormats(datasetsRepository) const getDatasetTemplates = new GetDatasetTemplates(datasetsRepository) +const updateTermsOfAccess = new UpdateTermsOfAccess(datasetsRepository) const updateDatasetLicense = new UpdateDatasetLicense(datasetsRepository) export { @@ -106,6 +108,7 @@ export { getDatasetAvailableCategories, getDatasetCitationInOtherFormats, getDatasetTemplates, + updateTermsOfAccess, getDatasetAvailableDatasetTypes, getDatasetAvailableDatasetType, addDatasetType, diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index 6edf7b95..849cf658 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -29,6 +29,8 @@ import { DatasetTemplate } from '../../domain/models/DatasetTemplate' import { DatasetTemplatePayload } from './transformers/DatasetTemplatePayload' import { transformDatasetTemplatePayloadToDatasetTemplate } from './transformers/datasetTemplateTransformers' import { DatasetType } from '../../domain/models/DatasetType' +import { TermsOfAccess } from '../../domain/models/Dataset' +import { transformTermsOfAccessToUpdatePayload } from './transformers/termsOfAccessTransformers' import { DatasetLicenseUpdateRequest } from '../../domain/dtos/DatasetLicenseUpdateRequest' import { DatasetTypeDTO } from '../../domain/dtos/DatasetTypeDTO' @@ -486,6 +488,20 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi }) } + public async updateTermsOfAccess( + datasetId: number | string, + termsOfAccess: TermsOfAccess + ): Promise { + return this.doPut( + this.buildApiEndpoint(this.datasetsResourceName, 'access', datasetId), + transformTermsOfAccessToUpdatePayload(termsOfAccess) + ) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + public async updateDatasetLicense( datasetId: number | string, payload: DatasetLicenseUpdateRequest diff --git a/src/datasets/infra/repositories/transformers/termsOfAccessTransformers.ts b/src/datasets/infra/repositories/transformers/termsOfAccessTransformers.ts new file mode 100644 index 00000000..1e9be77d --- /dev/null +++ b/src/datasets/infra/repositories/transformers/termsOfAccessTransformers.ts @@ -0,0 +1,31 @@ +import { TermsOfAccess } from '../../../domain/models/Dataset' + +export const transformTermsOfAccessToUpdatePayload = ( + terms: TermsOfAccess & { termsOfAccess?: string } +) => { + const { + fileAccessRequest, + dataAccessPlace, + originalArchive, + availabilityStatus, + contactForAccess, + sizeOfCollection, + studyCompletion + } = terms + + const termsOfAccessForRestrictedFiles = + terms.termsOfAccess ?? terms.termsOfAccessForRestrictedFiles + + return { + customTermsOfAccess: { + fileAccessRequest, + termsOfAccess: termsOfAccessForRestrictedFiles, + dataAccessPlace, + originalArchive, + availabilityStatus, + contactForAccess, + sizeOfCollection, + studyCompletion + } + } +} diff --git a/test/functional/datasets/UpdateTermsOfAccess.test.ts b/test/functional/datasets/UpdateTermsOfAccess.test.ts new file mode 100644 index 00000000..14317a91 --- /dev/null +++ b/test/functional/datasets/UpdateTermsOfAccess.test.ts @@ -0,0 +1,51 @@ +import { TestConstants } from '../../testHelpers/TestConstants' +import { + ApiConfig, + DataverseApiAuthMechanism +} from '../../../src/core/infra/repositories/ApiConfig' +import { + createDataset, + DatasetNotNumberedVersion, + getDataset, + updateTermsOfAccess +} from '../../../src/datasets' +import { WriteError } from '../../../src' + +describe('UpdateTermsOfAccess (functional)', () => { + beforeAll(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should update terms of access with provided fields', async () => { + const ids = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await updateTermsOfAccess.execute(ids.numericId, { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'Your terms', + dataAccessPlace: 'Place' + }) + + const dataset = await getDataset.execute( + ids.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + + expect(dataset.termsOfUse.termsOfAccess.fileAccessRequest).toBe(true) + expect(dataset.termsOfUse.termsOfAccess.termsOfAccessForRestrictedFiles).toBe('Your terms') + expect(dataset.termsOfUse.termsOfAccess.dataAccessPlace).toBe('Place') + }) + + test('should throw when dataset does not exist', async () => { + await expect( + updateTermsOfAccess.execute(999999, { + fileAccessRequest: false + }) + ).rejects.toBeInstanceOf(WriteError) + }) +}) diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index ac8967c9..5e3fa4b1 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -28,6 +28,7 @@ import { deleteDatasetType, linkDatasetTypeWithMetadataBlocks, setAvailableLicensesForDatasetType, + updateTermsOfAccess, DatasetLicenseUpdateRequest } from '../../../src/datasets' import { ApiConfig, WriteError } from '../../../src' @@ -37,7 +38,8 @@ import { Author, DatasetContact, DatasetDescription, - Publication + Publication, + TermsOfAccess } from '../../../src/datasets/domain/models/Dataset' import { createCollectionViaApi, @@ -1523,9 +1525,6 @@ describe('DatasetsRepository', () => { await waitForNoLocks(testDatasetIds.numericId, 10) } - const summaries = await sut.getDatasetVersionsSummaries(testDatasetIds.numericId) - console.log('summaries', summaries) - const firstPage = await sut.getDatasetVersionsSummaries(testDatasetIds.numericId, 5, 0) expect(firstPage.summaries.length).toBe(5) @@ -1959,6 +1958,139 @@ describe('DatasetsRepository', () => { }) }) + describe('updateTermsOfAccess', () => { + let testDatasetIds: CreatedDatasetIdentifiers + + beforeAll(async () => { + testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + }) + + test('should update the terms of access for a dataset', async () => { + const datasetBefore = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + + const termsOfAccessBefore: TermsOfAccess = { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: undefined, + dataAccessPlace: undefined, + originalArchive: undefined, + availabilityStatus: undefined, + contactForAccess: undefined, + sizeOfCollection: undefined, + studyCompletion: undefined + } + expect(datasetBefore.termsOfUse.termsOfAccess).toEqual(termsOfAccessBefore) + + const termsOfAccessAfter: TermsOfAccess = { + fileAccessRequest: false, + termsOfAccessForRestrictedFiles: 'Your terms of access for restricted files', + dataAccessPlace: 'Your data access place', + originalArchive: 'Your original archive', + availabilityStatus: 'Your availability status', + contactForAccess: 'Your contact for access', + sizeOfCollection: 'Your size of collection', + studyCompletion: 'Your study completion' + } + + await updateTermsOfAccess.execute(testDatasetIds.numericId, termsOfAccessAfter) + + const datasetAfter = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + + expect(datasetAfter.termsOfUse.termsOfAccess).toEqual(termsOfAccessAfter) + }) + + test('should throw error when dataset does not exist', async () => { + const nonExistentId = 999999 + await expect( + updateTermsOfAccess.execute(nonExistentId, { + fileAccessRequest: true + }) + ).rejects.toBeInstanceOf(WriteError) + }) + + test('should accept only fileAccessRequest field', async () => { + const ids = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await updateTermsOfAccess.execute(ids.numericId, { + fileAccessRequest: false + }) + + const dataset = await sut.getDataset( + ids.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + + expect(dataset.termsOfUse.termsOfAccess.fileAccessRequest).toBe(false) + expect(dataset.termsOfUse.termsOfAccess.dataAccessPlace).toBeUndefined() + expect(dataset.termsOfUse.termsOfAccess.originalArchive).toBeUndefined() + expect(dataset.termsOfUse.termsOfAccess.availabilityStatus).toBeUndefined() + expect(dataset.termsOfUse.termsOfAccess.contactForAccess).toBeUndefined() + expect(dataset.termsOfUse.termsOfAccess.sizeOfCollection).toBeUndefined() + expect(dataset.termsOfUse.termsOfAccess.studyCompletion).toBeUndefined() + }) + + test('should work when identifying dataset by persistent id', async () => { + const ids = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await updateTermsOfAccess.execute(ids.persistentId, { + termsOfAccessForRestrictedFiles: 'Persistent terms', + fileAccessRequest: false + }) + + const dataset = await sut.getDataset( + ids.persistentId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + + expect(dataset.persistentId).toBe(ids.persistentId) + expect(dataset.termsOfUse.termsOfAccess.fileAccessRequest).toBe(false) + expect(dataset.termsOfUse.termsOfAccess.termsOfAccessForRestrictedFiles).toBe( + 'Persistent terms' + ) + }) + + test('should update terms on a published dataset (creates a draft)', async () => { + const ids = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await publishDataset.execute(ids.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(ids.numericId, 10) + + await updateTermsOfAccess.execute(ids.numericId, { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'Updated after publish' + }) + + await waitForNoLocks(ids.numericId, 10) + + const dataset = await sut.getDataset( + ids.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + + expect(dataset.versionInfo.state).toBe('DRAFT') + expect(dataset.termsOfUse.termsOfAccess.termsOfAccessForRestrictedFiles).toBe( + 'Updated after publish' + ) + + await deletePublishedDatasetViaApi(ids.persistentId) + }) + }) + describe('updateDatasetLicense', () => { test('should update the license of a published dataset', async () => { const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) diff --git a/test/unit/datasets/UpdateTermsOfAccess.test.ts b/test/unit/datasets/UpdateTermsOfAccess.test.ts new file mode 100644 index 00000000..ec713962 --- /dev/null +++ b/test/unit/datasets/UpdateTermsOfAccess.test.ts @@ -0,0 +1,34 @@ +import { WriteError } from '../../../src' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { UpdateTermsOfAccess } from '../../../src/datasets/domain/useCases/UpdateTermsOfAccess' +import { TermsOfAccess } from '../../../src/datasets/domain/models/Dataset' + +describe('UpdateTermsOfAccess (unit)', () => { + test('should return undefined on updating TermsOfAccess with repository success', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.updateTermsOfAccess = jest.fn().mockResolvedValue(undefined) + + const sut = new UpdateTermsOfAccess(datasetsRepositoryStub) + const termsOfAccess: TermsOfAccess = { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'Your terms', + dataAccessPlace: 'Place' + } + + const actual = await sut.execute(1, termsOfAccess) + expect(actual).toEqual(undefined) + }) + + test('should return error result on updating TermsOfAccess with repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.updateTermsOfAccess = jest.fn().mockRejectedValue(new WriteError()) + const sut = new UpdateTermsOfAccess(datasetsRepositoryStub) + + const nonExistentDatasetId = 111111 + await expect( + sut.execute(nonExistentDatasetId, { + fileAccessRequest: true + }) + ).rejects.toThrow(WriteError) + }) +})