Skip to content
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ Thank you for your interest in contributing to our project!
7. _Optional_ Add [loggers.json](#loggers-configuration) file to the root folder and configure multiple loggers.
8. _Optional_ Add [proposalTypes.json](#proposal-types-configuration) file to the root folder and configure the proposal types.
9. _Optional_ Add [datasetTypes.json](#dataset-types-configuration) file to the root folder and configure the dataset types.
10. `npm run start:dev`
11. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas.
12. To be able to run the e2e tests with the same setup as in the Github actions you will need to run `npm run prepare:local` and after that run `npm run start:dev`. This will start all needed containers and copy some configuration to the right place.
10. _Optional_ Add [datasetExternalLinkTemplates.json](#dataset-external-link-templates-configuration) file to the root folder and configure the external link types.
11. `npm run start:dev`
12. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas.
13. To be able to run the e2e tests with the same setup as in the Github actions you will need to run `npm run prepare:local` and after that run `npm run start:dev`. This will start all needed containers and copy some configuration to the right place.

## Develop in a container using the docker-compose.dev file

Expand All @@ -57,11 +58,12 @@ Thank you for your interest in contributing to our project!
5. _Optional_ Mount [loggers.json](#loggers-configuration) file to a volume in the container to configure multiple loggers.
6. _Optional_ Mount [proposalTypes.json](#proposal-types-configuration) file to a volume in the container to configure the proposal types.
7. _Optional_ Mount [datasetTypes.json](#dataset-types-configuration) file to a volume in the container to configure the dataset types.
8. _Optional_ Change the container env variables.
9. _Optional_ Create the file test/config/.env.override to override ENV vars that are used when running the tests.
10. Attach to the container.
11. `npm run start:dev`
12. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas.
8. _Optional_ Mount [datasetExternalLinkTemplates.json](#dataset-external-link-templates-configuration) file to a volume in the container to configure the external link types.
9. _Optional_ Change the container env variables.
10. _Optional_ Create the file test/config/.env.override to override ENV vars that are used when running the tests.
11. Attach to the container.
12. `npm run start:dev`
13. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas.

## Test the app

Expand Down Expand Up @@ -113,16 +115,26 @@ The `loggers.example.json` file in the root directory showcases the example of c

### Proposal types configuration

Providing a file called _proposalTypes.json_ at the root of the project, locally or in the container, will be automatically loaded into the application configuration service under property called `proposalTypes` and used for validation against proposal creation and update.
If a file called _proposalTypes.json_ is provided at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under the property `proposalTypes`.

This content is used for validation against proposal creation and update.

The `proposalTypes.json.example` file in the root directory showcases the example of configuration structure for proposal types.
The file `proposalTypes.example.json` contains an example.

### Dataset types configuration

When providing a file called _datasetTypes.json_ at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under property called `datasetTypes` and used for validation against dataset creation and update. The types `Raw` and `Derived` are always valid dataset types by default.
If a file called _datasetTypes.json_ is provided at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under the property `datasetTypes`.

The `datasetTypes.example.json` file in the root directory showcases an example of configuration structure for dataset types.

### Dataset external link templates configuration

If a file called _datasetExternalLinkTemplates.json_ is provided at at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under the property `datasetExternalLinkTemplates`.

The content is used to create links to external websites from individual datasets, based on criteria applied to the dataset metadata.

The file `datasetExternalLinkTemplates.example.json` contains an example.

### Published data configuration

Providing a file called _publishedDataConfig.json_ at the root of the project, locally or in the container, will be automatically loaded into the application configuration service under property called `publishedDataConfig`. It will be used for published data metadata form generation in the frontend and metadata validation in publication and registration of the published data.
Expand Down
14 changes: 14 additions & 0 deletions datasetExternalLinkTemplates.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"title": "Franzviewer II",
"url_template": "https://franz.site.com/franzviewer?id=${dataset.pid}",
"description_template": "View ${dataset.numberOfFiles} files in Franz' own personal viewer",
"filter": "(dataset.type == 'derived') && dataset.owner.includes('Franz')"
},
{
"title": "High Beam-Energy View",
"url_template": "https://beamviewer.beamline.net/highenergy?id=${dataset.pid}",
"description_template": "The high-energy beamviewer (value ${dataset.scientificMetadata?.beamEnergy?.value}) at beamCo",
"filter": "(dataset.scientificMetadata?.beamEnergy?.value > 20)"
}
]
5 changes: 5 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const configuration = () => {
};
const jsonConfigMap: { [key: string]: object | object[] | boolean } = {
datasetTypes: {},
datasetExternalLinkTemplates: [],
proposalTypes: {},
};
const jsonConfigFileList: { [key: string]: string } = {
Expand All @@ -80,6 +81,9 @@ const configuration = () => {
process.env.FRONTEND_THEME_FILE || "./src/config/frontend.theme.json",
loggers: process.env.LOGGERS_CONFIG_FILE || "loggers.json",
datasetTypes: process.env.DATASET_TYPES_FILE || "datasetTypes.json",
datasetExternalLinkTemplates:
process.env.DATASET_EXTERNAL_LINK_TEMPLATES_FILE ||
"datasetExternalLinkTemplates.json",
proposalTypes: process.env.PROPOSAL_TYPES_FILE || "proposalTypes.json",
metricsConfig: process.env.METRICS_CONFIG_FILE || "metricsConfig.json",
publishedDataConfig:
Expand Down Expand Up @@ -402,6 +406,7 @@ const configuration = () => {
policyRetentionShiftInYears: process.env.POLICY_RETENTION_SHIFT ?? -1,
},
datasetTypes: jsonConfigMap.datasetTypes,
datasetExternalLinkTemplates: jsonConfigMap.datasetExternalLinkTemplates,
proposalTypes: jsonConfigMap.proposalTypes,
frontendConfig: jsonConfigMap.frontendConfig,
frontendTheme: jsonConfigMap.frontendTheme,
Expand Down
44 changes: 42 additions & 2 deletions src/datasets/datasets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { PartialUpdateDatablockDto } from "src/datablocks/dto/update-datablock.d
import { Datablock } from "src/datablocks/schemas/datablock.schema";
import { LogbooksService } from "src/logbooks/logbooks.service";
import { Logbook } from "src/logbooks/schemas/logbook.schema";
import { ExternalLinkClass } from "./schemas/externallink.class";
import { CreateDatasetOrigDatablockDto } from "src/origdatablocks/dto/create-dataset-origdatablock";
import { CreateOrigDatablockDto } from "src/origdatablocks/dto/create-origdatablock.dto";
import { UpdateOrigDatablockDto } from "src/origdatablocks/dto/update-origdatablock.dto";
Expand Down Expand Up @@ -1169,8 +1170,7 @@ export class DatasetsController {
@Get("/findOne")
@ApiOperation({
summary: "It returns the first dataset found.",
description:
"It returns the first dataset of the ones that matches the filter provided. The list returned can be modified by providing a filter.",
description: "Returns the first dataset that matches the provided filters.",
})
@ApiQuery({
name: "filter",
Expand Down Expand Up @@ -1751,6 +1751,46 @@ export class DatasetsController {
return await this.convertCurrentToObsoleteSchema(outputDatasetDto);
}

// GET /datasets/:id/externallinks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add new endpoints to the old v3 controller?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the right move is, honestly.
I'd be happy to add them but it would of course be a change to the v3 API (though only an additive one). Are we still changing that API or is it in a static state now that we have v4?

@UseGuards(PoliciesGuard)
@CheckPolicies(
"datasets",
(ability: AppAbility) =>
ability.can(Action.DatasetRead, DatasetClass) ||
ability.can(Action.DatasetReadOnePublic, DatasetClass),
)
@Get("/:pid/externallinks")
@ApiOperation({
summary: "Returns dataset external links.",
description:
"Returns the applicable external links for the dataset with the given pid.",
})
@ApiParam({
name: "pid",
description: "Id of the dataset to return external links",
type: String,
})
@ApiResponse({
status: HttpStatus.OK,
type: ExternalLinkClass,
isArray: true,
description: "A list of exernal link objects.",
})
async findExternalLinksById(
@Req() request: Request,
@Param("pid") id: string,
) {
const links = await this.datasetsService.findExternalLinksById(id);

await this.checkPermissionsForDatasetExtended(
request,
id,
Action.DatasetRead,
);

return links;
}

// GET /datasets/:id/thumbnail
@UseGuards(PoliciesGuard)
@CheckPolicies(
Expand Down
51 changes: 51 additions & 0 deletions src/datasets/datasets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
IDatasetRelation,
IDatasetScopes,
} from "./interfaces/dataset-filters.interface";
import { ExternalLinkClass } from "./schemas/externallink.class";
import { DatasetClass, DatasetDocument } from "./schemas/dataset.schema";
import {
DATASET_LOOKUP_FIELDS,
Expand Down Expand Up @@ -485,6 +486,56 @@ export class DatasetsService {
throw new NotFoundException(error);
}
}

async findExternalLinksById(id: string): Promise<ExternalLinkClass[]> {
const thisDataSet = await this.findOneComplete({
where: { pid: id },
include: [DatasetLookupKeysEnum.all],
});

if (!thisDataSet) {
// no luck. we need to create a new dataset
throw new NotFoundException(`Dataset #${id} not found`);
}

interface ExternalLinkTemplateConfig {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be nice to have interfaces/types in separate file, but that it just my subjective view.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I agree. Right now this type is only used once, and in this spot. But I think we should be using types like this when we're parsing/validating all the JSON that this config data comes from. That may be a task best done en-masse though, as a separate change...

title: string;
url_template: string;
description_template: string;
filter: string;
}

const templates: ExternalLinkTemplateConfig[] | undefined =
this.configService.get("datasetExternalLinkTemplates");
if (!templates) {
return [];
}

return templates
.filter((template) => {
const filterFn = new Function(
"dataset",
`return (${template.filter});`,
);
return filterFn(thisDataSet);
})
.map((template) => {
const urlFn = new Function(
"dataset",
`return (\`${template.url_template}\`);`,
);
const descriptionFn = new Function(
"dataset",
`return (\`${template.description_template}\`);`,
);
return {
url: urlFn(thisDataSet),
title: template.title,
description: descriptionFn(thisDataSet),
};
});
}

// Get metadata keys
async metadataKeys(
filters: IFilters<DatasetDocument, IDatasetFields>,
Expand Down
38 changes: 38 additions & 0 deletions src/datasets/datasets.v4.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import { HistoryClass } from "./schemas/history.schema";
import { LifecycleClass } from "./schemas/lifecycle.schema";
import { RelationshipClass } from "./schemas/relationship.schema";
import { TechniqueClass } from "./schemas/technique.schema";
import { ExternalLinkClass } from "./schemas/externallink.class";

@ApiBearerAuth()
@ApiExtraModels(
Expand Down Expand Up @@ -691,6 +692,43 @@ export class DatasetsV4Controller {
return this.datasetsService.count(finalFilters);
}

// GET /datasets/:id/externallinks
@UseGuards(PoliciesGuard)
@CheckPolicies("datasets", (ability: AppAbility) =>
ability.can(Action.DatasetRead, DatasetClass),
)
@Get("/:pid/externallinks")
@ApiOperation({
summary: "Returns dataset external links.",
description:
"Returns the applicable external links for the dataset with the given pid.",
})
@ApiParam({
name: "pid",
description: "Id of the dataset to return external links",
type: String,
})
@ApiResponse({
status: HttpStatus.OK,
type: ExternalLinkClass,
isArray: true,
description: "A list of exernal link objects.",
})
async findExternalLinksById(
@Req() request: Request,
@Param("pid") id: string,
) {
const links = await this.datasetsService.findExternalLinksById(id);

await this.checkPermissionsForDatasetExtended(
request,
id,
Action.DatasetRead,
);

return links;
}

// GET /datasets/:id
//@UseGuards(PoliciesGuard)
@UseGuards(PoliciesGuard)
Expand Down
32 changes: 32 additions & 0 deletions src/datasets/schemas/externallink.class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString } from "class-validator";

// This class defines the externalLinks field in a dataset.
// That field is not represented in the Mongoose data store,
// so there is no equivalent schema representation for it.

export class ExternalLinkClass {
@ApiProperty({
Copy link
Member

@emigun emigun Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could consider having these api properties automatically generated as we for example did in the samples module (if I remember correctly)

let me know if you want more information

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean by using SchemaFactory? I don't think we can do that here because these properties have no representations in MongoDb ... But I could be wrong. I'd love more information!

type: String,
required: true,
description: "URL of the external link.",
})
@IsString()
readonly url: string;

@ApiProperty({
type: String,
required: true,
description: "Text to display representing the external link.",
})
@IsString()
readonly title: string;

@ApiProperty({
type: String,
required: false,
description: "Description of the link destination.",
})
@IsString()
readonly description?: string;
}