diff --git a/db-management/migrations/20250818105519-dataset-viewer-pg-changes.js b/db-management/migrations/20250818105519-dataset-viewer-pg-changes.js
new file mode 100644
index 0000000..4e3c876
--- /dev/null
+++ b/db-management/migrations/20250818105519-dataset-viewer-pg-changes.js
@@ -0,0 +1,53 @@
+'use strict';
+
+var dbm;
+var type;
+var seed;
+var fs = require('fs');
+var path = require('path');
+var Promise;
+
+/**
+ * We receive the dbmigrate dependency from dbmigrate initially.
+ * This enables us to not have to rely on NODE_PATH.
+ */
+exports.setup = function(options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+ Promise = options.Promise;
+};
+
+exports.up = function(db) {
+ var filePath = path.join(__dirname, 'sqls', '20250818105519-dataset-viewer-pg-changes-up.sql');
+ return new Promise( function( resolve, reject ) {
+ fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
+ if (err) return reject(err);
+ console.log('received data: ' + data);
+
+ resolve(data);
+ });
+ })
+ .then(function(data) {
+ return db.runSql(data);
+ });
+};
+
+exports.down = function(db) {
+ var filePath = path.join(__dirname, 'sqls', '20250818105519-dataset-viewer-pg-changes-down.sql');
+ return new Promise( function( resolve, reject ) {
+ fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
+ if (err) return reject(err);
+ console.log('received data: ' + data);
+
+ resolve(data);
+ });
+ })
+ .then(function(data) {
+ return db.runSql(data);
+ });
+};
+
+exports._meta = {
+ "version": 1
+};
diff --git a/db-management/migrations/sqls/20250818105519-dataset-viewer-pg-changes-down.sql b/db-management/migrations/sqls/20250818105519-dataset-viewer-pg-changes-down.sql
new file mode 100644
index 0000000..88b6240
--- /dev/null
+++ b/db-management/migrations/sqls/20250818105519-dataset-viewer-pg-changes-down.sql
@@ -0,0 +1 @@
+DROP COLUMN IF EXISTS data_viewer_config;
\ No newline at end of file
diff --git a/db-management/migrations/sqls/20250818105519-dataset-viewer-pg-changes-up.sql b/db-management/migrations/sqls/20250818105519-dataset-viewer-pg-changes-up.sql
new file mode 100644
index 0000000..4c86e45
--- /dev/null
+++ b/db-management/migrations/sqls/20250818105519-dataset-viewer-pg-changes-up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE public.project_group
+ADD COLUMN IF NOT EXISTS data_viewer_config JSON DEFAULT '{}'::JSON;
\ No newline at end of file
diff --git a/src/assets/user-management-spec.json b/src/assets/user-management-spec.json
index ebf22e6..323dc66 100644
--- a/src/assets/user-management-spec.json
+++ b/src/assets/user-management-spec.json
@@ -773,6 +773,15 @@
}
}
},
+ {
+ "name": "data_viewer_allowed",
+ "in": "query",
+ "description": "Data viewer allowed: Filters project groups based on whether data viewer access is allowed. Uses a boolean value.",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
{
"name": "page_no",
"in": "query",
@@ -1058,6 +1067,51 @@
}
}
},
+ "/api/v1/project-group/{projectGroupId}/dataset-viewer": {
+ "post": {
+ "tags": [
+ "ProjectGroup"
+ ],
+ "summary": "Updates the project group data viewer preferences",
+ "description": "Updates the project group data viewer preferences.",
+ "operationId": "updateDataViewer",
+ "parameters": [
+ {
+ "name": "projectGroupId",
+ "in": "path",
+ "description": "The ID of the project group to update.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/data_viewer"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Project group data viewer preferences updated successfully."
+ },
+ "401": {
+ "description": "Unauthenticated request"
+ },
+ "403": {
+ "description": "Unauthorized request"
+ },
+ "500": {
+ "description": "An server error occurred."
+ }
+ }
+ }
+ },
"/api/v1/reset-credentials": {
"post": {
"tags": [
@@ -1188,6 +1242,10 @@
"type": "string",
"description": "Role associated with project group."
}
+ },
+ "data_viewer_config": {
+ "$ref": "#/components/schemas/data_viewer",
+ "description": "Configuration settings for the dataset viewer."
}
},
"description": "User associated project groups and roles."
@@ -1441,6 +1499,40 @@
},
"description": "Describes an Project Group."
},
+ "data_viewer": {
+ "type": "object",
+ "properties": {
+ "dataset_viewer_allowed": {
+ "type": "boolean",
+ "default": false,
+ "description": "Flag to indicate to allow all datasets within the project group to be viewed on the dataset viewer."
+ },
+ "feedback_turnaround_time": {
+ "required": [
+ "number",
+ "units"
+ ],
+ "type": "object",
+ "description": "Feedback turnaround time for the project group.",
+ "properties": {
+ "number": {
+ "type": "integer",
+ "description": "Number of days for feedback turnaround time."
+ },
+ "units": {
+ "type": "string",
+ "enum": [
+ "days",
+ "months",
+ "years"
+ ],
+ "description": "Unit of time for feedback turnaround time."
+ }
+ }
+ }
+ },
+ "description": "Describes an Project group."
+ },
"ProjectGroupList": {
"type": "object",
"properties": {
@@ -1475,9 +1567,12 @@
"$ref": "#/components/schemas/POC",
"description": "POC details"
}
+ },
+ "data_viewer": {
+ "$ref": "#/components/schemas/data_viewer",
+ "description": "Describes an Project group."
}
- },
- "description": "Describes an Project group."
+ }
},
"POC": {
"type": "object",
diff --git a/src/controller/project-group-controller.ts b/src/controller/project-group-controller.ts
index 1dc9763..220a5dc 100644
--- a/src/controller/project-group-controller.ts
+++ b/src/controller/project-group-controller.ts
@@ -11,6 +11,7 @@ import { ProjectGroupUserQueryParams } from "../model/params/project-group-user-
import { Utility } from "../utility/utility";
import queryValidationMiddleware from "../middleware/query-params-validation-middleware";
import { listRequestValidation } from "../middleware/list-request-validation-middleware";
+import { DatasetViewerDto, FeedbackTurnaroundTime } from "../model/dto/dataset-viewer-dto";
class ProjectGroupController implements IController {
public path = '';
@@ -26,6 +27,28 @@ class ProjectGroupController implements IController {
this.router.get(`${this.path}/api/v1/project-group`, listRequestValidation, authorizationMiddleware([]), queryValidationMiddleware(ProjectGroupQueryParams), this.getProjectGroup);
this.router.get(`${this.path}/api/v1/project-group/:projectGroupId/users`, authorizationMiddleware([Role.TDEI_ADMIN, Role.POC], true), this.getProjectGroupUsers);
this.router.put(`${this.path}/api/v1/project-group/:projectGroupId/active/:status`, authorizationMiddleware([Role.TDEI_ADMIN]), this.deleteProjectGroup);
+ this.router.post(`${this.path}/api/v1/project-group/:projectGroupId/dataset-viewer`, authorizationMiddleware([Role.TDEI_ADMIN, Role.POC], true), this.putDatasetViewer);
+ }
+
+ /**
+ * Gets the dataset viewer configuration for a project group
+ * @param req - The request object
+ * @param res - The response object
+ * @param next - The next middleware function
+ */
+ async putDatasetViewer(req: Request, res: express.Response, next: NextFunction) {
+ try {
+ let projectGroupId = req.params.projectGroupId;
+ let datasetViewerConfig = DatasetViewerDto.from(req.body);
+ datasetViewerConfig.feedback_turnaround_time = FeedbackTurnaroundTime.from(datasetViewerConfig.feedback_turnaround_time);
+
+ await datasetViewerConfig.validateRequestInput();
+ const result = await projectGroupService.updateDatasetViewerConfig(projectGroupId, datasetViewerConfig);
+ Ok(res, result);
+ } catch (error) {
+ let errorMessage = "Error updating the dataset viewer config.";
+ Utility.handleError(res, next, error, errorMessage);
+ }
}
public deleteProjectGroup = async (request: Request, response: express.Response, next: NextFunction) => {
diff --git a/src/model/dto/dataset-viewer-dto.ts b/src/model/dto/dataset-viewer-dto.ts
new file mode 100644
index 0000000..a076639
--- /dev/null
+++ b/src/model/dto/dataset-viewer-dto.ts
@@ -0,0 +1,81 @@
+import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, validate, ValidateNested, ValidationError } from "class-validator";
+import { BaseDto } from "./base-dto";
+import { Prop } from "nodets-ms-core/lib/models";
+import { QueryConfig } from "pg";
+import { InputException } from "../../exceptions/http/http-exceptions";
+import { plainToInstance, Type } from "class-transformer";
+
+export enum TimeUnit {
+ DAYS = "days",
+ MONTHS = "months",
+ YEARS = "years"
+}
+
+export class FeedbackTurnaroundTime extends BaseDto {
+ @Prop()
+ @IsNotEmpty()
+ @IsNumber()
+ number!: number;
+
+ @Prop()
+ @IsNotEmpty()
+ @IsEnum(TimeUnit)
+ units!: TimeUnit;
+}
+
+export class DatasetViewerDto extends BaseDto {
+ @Prop()
+ @IsNotEmpty()
+ @IsBoolean()
+ dataset_viewer_allowed!: boolean;
+
+ @Prop()
+ @IsNotEmpty()
+ @ValidateNested()
+ @Type(() => FeedbackTurnaroundTime)
+ feedback_turnaround_time!: FeedbackTurnaroundTime;
+
+ constructor(init?: Partial) {
+ super();
+ Object.assign(this, init);
+ }
+
+ async validateRequestInput() {
+ // Ensure nested object exists
+ // if (!this.feedback_turnaround_time) {
+ // this.feedback_turnaround_time = new FeedbackTurnaroundTime();
+ // }
+ // const dto = plainToInstance(this.constructor as new () => DatasetViewerDto, this);
+ let errors = await validate(this);
+ if (errors.length > 0) {
+ console.log('Input validation failed');
+ let message = errors
+ .map((error: ValidationError) => {
+ if (error.constraints) {
+ return Object.values(error.constraints);
+ }
+ if (error.children && error.children.length > 0) {
+ // Nested validation messages
+ return error.children.map(c => Object.values(c.constraints || {})).join(', ');
+ }
+ return '';
+ })
+ .join(', ');
+
+ throw new InputException(`Required fields are missing or invalid: ${message}`);
+ }
+ return true;
+ }
+
+ /**
+ * Builds the update dataset viewer QueryConfig object
+ * @returns QueryConfig object
+ */
+ getUpdateDatasetViewerQuery(tdei_project_group_id: string): QueryConfig {
+ const queryObject = {
+ text: `UPDATE project_group SET data_viewer_config = $1 WHERE project_group_id = $2`,
+ values: [JSON.stringify(this), tdei_project_group_id]
+ }
+ return queryObject;
+ }
+}
\ No newline at end of file
diff --git a/src/model/dto/project-group-dto.ts b/src/model/dto/project-group-dto.ts
index df589c2..82a5a6a 100644
--- a/src/model/dto/project-group-dto.ts
+++ b/src/model/dto/project-group-dto.ts
@@ -4,6 +4,7 @@ import { BaseDto } from "./base-dto";
import { Prop } from "nodets-ms-core/lib/models";
import { QueryConfig } from "pg";
import { FeatureCollection } from "geojson";
+import { DatasetViewerDto } from "./dataset-viewer-dto";
export class ProjectGroupDto extends BaseDto {
@Prop()
@@ -32,6 +33,9 @@ export class ProjectGroupDto extends BaseDto {
@IsValidPolygon()
@Prop()
polygon!: FeatureCollection;
+ @IsOptional()
+ @Prop()
+ data_viewer_config!: DatasetViewerDto;
constructor(init?: Partial) {
super();
diff --git a/src/model/dto/project-group-role-dto.ts b/src/model/dto/project-group-role-dto.ts
index 8dfb40c..c966764 100644
--- a/src/model/dto/project-group-role-dto.ts
+++ b/src/model/dto/project-group-role-dto.ts
@@ -1,8 +1,10 @@
+import { DatasetViewerDto } from "./dataset-viewer-dto";
+
export class ProjectGroupRoleDto {
tdei_project_group_id!: string;
project_group_name!: string;
roles!: string[];
-
+ data_viewer_config!: DatasetViewerDto;
constructor(init?: Partial) {
Object.assign(this, init);
}
diff --git a/src/model/params/project-group-get-query-params.ts b/src/model/params/project-group-get-query-params.ts
index b5b189e..0c8395c 100644
--- a/src/model/params/project-group-get-query-params.ts
+++ b/src/model/params/project-group-get-query-params.ts
@@ -27,6 +27,9 @@ export class ProjectGroupQueryParams extends AbstractDomainEntity {
@IsOptional()
@Prop()
show_inactive!: boolean;
+ @Prop()
+ @IsOptional()
+ data_viewer_allowed: boolean | undefined;
constructor(init?: Partial) {
super();
@@ -43,7 +46,7 @@ export class ProjectGroupQueryParams extends AbstractDomainEntity {
COALESCE(json_agg(json_build_object('email', ue.email, 'username', ue.username, 'first_name',
ue.first_name,'last_name', ue.last_name,'enabled', ue.enabled)
) FILTER (WHERE ue.username IS NOT NULL), '[]')
- as userDetails
+ as userDetails, o.data_viewer_config
from project_group o
left join user_roles ur on o.project_group_id = ur.project_group_id and ur.role_id = (select role_id from roles where name='poc' limit 1)
left join keycloak.user_entity ue on ur.user_id = ue.id AND ue.enabled = true
@@ -73,9 +76,35 @@ export class ProjectGroupQueryParams extends AbstractDomainEntity {
//Always pull active project group
queryObject.condition(` o.is_active = $${queryObject.paramCouter++} `, true);
}
+ if (this.data_viewer_allowed != undefined) {
+ let dataViewerAllowed = this.toBoolean(this.data_viewer_allowed);
+ if (dataViewerAllowed) {
+ queryObject.condition(
+ `o.data_viewer_config IS NOT NULL
+ AND (o.data_viewer_config::json ->> 'dataset_viewer_allowed') IS NOT NULL
+ AND (o.data_viewer_config::json ->> 'dataset_viewer_allowed')::boolean = $${queryObject.paramCouter++}`,
+ this.data_viewer_allowed
+ );
+ } else {
+ queryObject.condition(
+ `o.data_viewer_config IS NOT NULL
+ AND ( (o.data_viewer_config::json ->> 'dataset_viewer_allowed') IS NULL
+ OR (o.data_viewer_config::json ->> 'dataset_viewer_allowed')::boolean = $${queryObject.paramCouter++})`,
+ this.data_viewer_allowed
+ );
+ }
+ }
queryObject.buildGroupRaw("group by o.project_group_id, o.name, o.phone, o.address, o.polygon, o.url, o.is_active, ue.enabled ");
return queryObject;
}
+
+ toBoolean(value: any): boolean {
+ if (typeof value === "boolean") return value;
+ if (typeof value === "string") {
+ return value.toLowerCase() === "true";
+ }
+ return Boolean(value); // fallback: null/undefined -> false, numbers -> truthy/falsy
+ }
}
\ No newline at end of file
diff --git a/src/service/interface/project-group-interface.ts b/src/service/interface/project-group-interface.ts
index 0ac81f7..25c73bf 100644
--- a/src/service/interface/project-group-interface.ts
+++ b/src/service/interface/project-group-interface.ts
@@ -3,8 +3,10 @@ import { ProjectGroupDto } from "../../model/dto/project-group-dto";
import { ProjectGroupListResponse } from "../../model/dto/poc-details-dto";
import { ProjectGroupQueryParams } from "../../model/params/project-group-get-query-params";
import { ProjectGroupUserQueryParams } from "../../model/params/project-group-user-query-params";
+import { DatasetViewerDto } from "../../model/dto/dataset-viewer-dto";
export interface IProjectGroupService {
+ updateDatasetViewerConfig(projectGroupId: string, config: DatasetViewerDto): Promise;
getProjectGroupUsers(params: ProjectGroupUserQueryParams): Promise;
createProjectGroup(projectGroup: ProjectGroupDto): Promise;
updateProjectGroup(projectGroup: ProjectGroupDto): Promise;
diff --git a/src/service/project-group-service.ts b/src/service/project-group-service.ts
index 3366794..45c13d6 100644
--- a/src/service/project-group-service.ts
+++ b/src/service/project-group-service.ts
@@ -12,10 +12,34 @@ import { Geometry, Feature } from "geojson";
import format from "pg-format";
import { DEFAULT_PROJECT_GROUP } from "../constants/role-constants";
import HttpException from "../exceptions/http/http-base-exception";
-
+import { DatasetViewerDto } from "../model/dto/dataset-viewer-dto";
class ProjectGroupService implements IProjectGroupService {
+ /**
+ * Updates the dataset viewer configuration for a project group
+ * @param projectGroupId - The ID of the project group
+ * @param config - The dataset viewer configuration
+ * @returns A promise that resolves to true if the update was successful
+ */
+ async updateDatasetViewerConfig(projectGroupId: string, config: DatasetViewerDto): Promise {
+ // Assume there is a dataset_viewer_config table with columns: project_group_id, config (jsonb)
+
+ //Check project group id exists
+ if (projectGroupId) {
+ await this.getProjectGroupById(projectGroupId);
+ }
+
+ let query = config.getUpdateDatasetViewerQuery(projectGroupId);
+
+ try {
+ await dbClient.query(query);
+ return true;
+ } catch (e) {
+ throw e;
+ }
+ }
+
async setProjectGroupStatus(projectGroupId: string, status: boolean): Promise {
//Default project group should not be deactivated
const query = {
@@ -141,6 +165,7 @@ class ProjectGroupService implements IProjectGroupService {
let projectgroup = ProjectGroupListResponse.from(x);
projectgroup.tdei_project_group_id = x.project_group_id;
projectgroup.project_group_name = x.name;
+ projectgroup.data_viewer_config = DatasetViewerDto.from(x.data_viewer_config);
if (projectgroup.polygon) {
var polygon = JSON.parse(x.polygon) as Geometry;
projectgroup.polygon = {
diff --git a/src/service/user-management-service.ts b/src/service/user-management-service.ts
index ec403a7..b07055a 100644
--- a/src/service/user-management-service.ts
+++ b/src/service/user-management-service.ts
@@ -205,10 +205,10 @@ export class UserManagementService implements IUserManagement {
let searchQuery = '';
if (searchText && searchText.length > 0) {
- searchQuery = format('SELECT o.name as project_group_name, o.project_group_id, ARRAY_AGG(r.name) as roles FROM user_roles ur INNER JOIN roles r on r.role_id = ur.role_id INNER JOIN project_group o on ur.project_group_id = o.project_group_id AND o.is_active = true WHERE user_id = %L AND o.name ILIKE %L GROUP BY o.name,o.project_group_id LIMIT %L OFFSET %L', userId, searchText + '%', take, skip);
+ searchQuery = format('SELECT o.data_viewer_config, o.name as project_group_name, o.project_group_id, ARRAY_AGG(r.name) as roles FROM user_roles ur INNER JOIN roles r on r.role_id = ur.role_id INNER JOIN project_group o on ur.project_group_id = o.project_group_id AND o.is_active = true WHERE user_id = %L AND o.name ILIKE %L GROUP BY o.name,o.project_group_id LIMIT %L OFFSET %L', userId, searchText + '%', take, skip);
}
else {
- searchQuery = format('SELECT o.name as project_group_name, o.project_group_id, ARRAY_AGG(r.name) as roles FROM user_roles ur INNER JOIN roles r on r.role_id = ur.role_id INNER JOIN project_group o on ur.project_group_id = o.project_group_id AND o.is_active = true WHERE user_id = %L GROUP BY o.name,o.project_group_id LIMIT %L OFFSET %L', userId, take, skip);
+ searchQuery = format('SELECT o.data_viewer_config, o.name as project_group_name, o.project_group_id, ARRAY_AGG(r.name) as roles FROM user_roles ur INNER JOIN roles r on r.role_id = ur.role_id INNER JOIN project_group o on ur.project_group_id = o.project_group_id AND o.is_active = true WHERE user_id = %L GROUP BY o.name,o.project_group_id LIMIT %L OFFSET %L', userId, take, skip);
}
return await dbClient.query(searchQuery)
@@ -218,7 +218,7 @@ export class UserManagementService implements IUserManagement {
projectGroupRole.project_group_name = x.project_group_name;
projectGroupRole.tdei_project_group_id = x.project_group_id;
projectGroupRole.roles = x.roles;
-
+ projectGroupRole.data_viewer_config = x.data_viewer_config;
projectGroupRoleList.push(projectGroupRole);
});
return projectGroupRoleList;