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;