diff --git a/CHANGELOG.MD b/CHANGELOG.MD index aa0ae93f8..bb0a29e6b 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,4 +1,12 @@ -## Jul 7, 2026 +## July 07, 2026 + +- **Feature** Added ability to upload files to Object Storage during survey submission [🎟️ DEP-277](https://citz-gdx.atlassian.net/browse/DEP-277) + - Added a new public document endpoint to the API that generates pre-signed URLs for uploading files to Object Storage. This endpoint takes the file name and content type as input and returns a pre-signed URL that can be used to upload the file directly to Object Storage. + - The new API can also be used to generate pre-signed URLs for downloading files from Object Storage, as well as deleting files from Object Storage. + - Updated the Formio options to include callbacks for handling file uploads, downloads, and deletions. These callbacks use the new API to generate URLs and perform privileged operations on Object Storage, allowing users to upload files during survey submission without needing high-level access to the storage service. + - Cleaned up survey-related component code + - Updated the survey pages to use React Router loaders to fetch the survey data instead of using useState and useEffect, as well as making sure the loader returns promises (as opposed to awaiting the data) so that the survey page can be rendered while the data is being fetched. This improves the user experience by allowing the page to render faster and show a loading state while the survey data is being retrieved. + - Fixed authorization bug that did not allow Super Admins to view surveys - **Feature** Integrated new landing hero section and introduction [🎟️ DEP-315](https://citz-gdx.atlassian.net/browse/DEP-315) - Refactored existing landing code for structural simplicity diff --git a/api/src/api/models/submission.py b/api/src/api/models/submission.py index a37a6a12f..0eb9195bf 100644 --- a/api/src/api/models/submission.py +++ b/api/src/api/models/submission.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from typing import List +from typing import List, Optional from sqlalchemy import ForeignKey from sqlalchemy.dialects import postgresql @@ -26,20 +26,27 @@ class Submission(BaseModel): # pylint: disable=too-few-public-methods __tablename__ = 'submission' id = db.Column(db.Integer, primary_key=True, autoincrement=True) - submission_json = db.Column(postgresql.JSONB(astext_type=db.Text()), nullable=False, server_default='{}') - survey_id = db.Column(db.Integer, ForeignKey('survey.id', ondelete='CASCADE'), nullable=False) - engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=False) - participant_id = db.Column(db.Integer, ForeignKey('participant.id'), nullable=True) + submission_json = db.Column(postgresql.JSONB( + astext_type=db.Text()), nullable=False, server_default='{}') + survey_id = db.Column(db.Integer, ForeignKey( + 'survey.id', ondelete='CASCADE'), nullable=False) + engagement_id = db.Column(db.Integer, ForeignKey( + 'engagement.id', ondelete='CASCADE'), nullable=False) + participant_id = db.Column( + db.Integer, ForeignKey('participant.id'), nullable=True) reviewed_by = db.Column(db.String(50)) review_date = db.Column(db.DateTime) - comment_status_id = db.Column(db.Integer, ForeignKey('comment_status.id', ondelete='SET NULL')) + comment_status_id = db.Column(db.Integer, ForeignKey( + 'comment_status.id', ondelete='SET NULL')) has_personal_info = db.Column(db.Boolean, nullable=True) has_profanity = db.Column(db.Boolean, nullable=True) rejected_reason_other = db.Column(db.String(500), nullable=True) has_threat = db.Column(db.Boolean, nullable=True) notify_email = db.Column(db.Boolean(), default=True) - comments = db.relationship('Comment', backref='submission', cascade='all, delete') - staff_note = db.relationship('StaffNote', backref='submission', cascade='all, delete') + comments = db.relationship( + 'Comment', backref='submission', cascade='all, delete') + staff_note = db.relationship( + 'StaffNote', backref='submission', cascade='all, delete') @classmethod def get_by_survey_id(cls, survey_id) -> List[SubmissionSchema]: @@ -118,7 +125,7 @@ def update(cls, submission: SubmissionSchema, session=None) -> Submission: return query.first() @classmethod - def update_comment_status(cls, submission_id, comment: dict, session=None) -> Submission: + def update_comment_status(cls, submission_id, comment: dict, session=None) -> Optional[Submission]: """Update comment status.""" status_id = comment.get('status_id', None) has_personal_info = comment.get('has_personal_info', None) diff --git a/api/src/api/resources/document.py b/api/src/api/resources/document.py index be0274f4a..bb7444e40 100644 --- a/api/src/api/resources/document.py +++ b/api/src/api/resources/document.py @@ -14,22 +14,47 @@ """API endpoints for managing documents resource.""" from http import HTTPStatus +from typing import Any, cast from flask import jsonify, request from flask_cors import cross_origin from flask_restx import Namespace, Resource +from api.models import Engagement as EngagementModel +from api.models import Survey as SurveyModel from api.schemas.document import Document +from api.schemas.public_upload import PublicObjectAccessRequestSchema, PublicUploadAuthorizationRequestSchema +from api.services.email_verification_service import EmailVerificationService from api.services.object_storage_service import ObjectStorageService from api.utils.roles import Role from api.utils.tenant_validator import require_role from api.utils.util import allowedorigins, cors_preflight -API = Namespace('document', description='Endpoints for Document Storage Management') +API = Namespace( + 'document', description='Endpoints for Document Storage Management') """Custom exception messages""" +def _get_public_upload_scope(verification_token: str): + """Return the validated public upload scope for the provided verification token.""" + verification = cast( + dict[str, Any], EmailVerificationService().get_active(verification_token)) + survey_id = verification.get('survey_id') + if not survey_id: + raise ValueError('Survey not found.') + + survey = SurveyModel.get_open(survey_id) + if not survey: + raise PermissionError('Engagement not open to submissions.') + + engagement = EngagementModel.find_by_id(survey.engagement_id) + if not engagement: + raise ValueError('Engagement not found.') + + return verification, survey, engagement + + @cors_preflight('GET,OPTIONS') @API.route('/') class DocumentStorage(Resource): @@ -42,9 +67,133 @@ def post(): """Retrieve authentication properties for document storage.""" try: requestfilejson = request.get_json() - documents = Document().load(requestfilejson, many=True) + documents = cast(list[dict[str, Any]], + Document().load(requestfilejson, many=True)) return jsonify(ObjectStorageService().get_auth_headers(documents)), HTTPStatus.OK except KeyError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR except ValueError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('POST,OPTIONS') +@API.route('/public') +class PublicDocumentUploadAuthorization(Resource): + """Token-scoped public upload/download/delete authorization controller.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def post(): + """Retrieve upload authorization for a public survey file upload.""" + try: + request_json = request.get_json() + upload_request = cast( + dict[str, Any], + PublicUploadAuthorizationRequestSchema().load(request_json), + ) + + verification, survey, _ = _get_public_upload_scope( + upload_request['verification_token']) + + object_storage_service = ObjectStorageService() + object_key, unique_filename = object_storage_service.build_public_upload_key( + tenant_id=survey.tenant_id, + survey_id=survey.id, + verification_id=verification['id'], + filename=upload_request['filename'], + ) + signed_upload = object_storage_service.get_signed_upload_details( + object_key=object_key, + content_type=upload_request.get('content_type'), + ) + + return { + **signed_upload, + 'uniquefilename': unique_filename, + 'filename': upload_request['filename'], + 'content_type': upload_request['content_type'], + 'size': upload_request['size'], + }, HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.BAD_REQUEST + except PermissionError as err: + return {'message': str(err)}, HTTPStatus.FORBIDDEN + except ValueError as err: + return {'message': str(err)}, HTTPStatus.BAD_REQUEST + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(): + """Retrieve download authorization for a public survey file upload.""" + try: + file_id = request.args.get('file_id') + verification_token = request.headers.get('Verification-Token') + if not file_id or not verification_token: + return {'message': 'Missing required parameters.'}, HTTPStatus.BAD_REQUEST + access_request = cast( + dict[str, Any], + PublicObjectAccessRequestSchema().load({ + 'file_id': request.args.get('file_id'), + 'verification_token': request.headers.get('Verification-Token'), + }), + ) + verification, survey, _ = _get_public_upload_scope( + access_request['verification_token']) + + object_storage_service = ObjectStorageService() + object_key = object_storage_service.get_object_key( + access_request['file_id']) + expected_prefix = object_storage_service.build_public_upload_prefix( + tenant_id=survey.tenant_id, + survey_id=survey.id, + verification_id=verification['id'], + ) + if not object_key.startswith(expected_prefix): + return {'message': 'Invalid file requested.'}, HTTPStatus.FORBIDDEN + + return object_storage_service.get_signed_request_details('GET', object_key), HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.BAD_REQUEST + except PermissionError as err: + return {'message': str(err)}, HTTPStatus.FORBIDDEN + except ValueError as err: + return {'message': str(err)}, HTTPStatus.BAD_REQUEST + + @staticmethod + @cross_origin(origins=allowedorigins()) + def delete(): + """Delete a public survey upload for a valid verification token.""" + try: + headers = request.headers + args = request.args + if not args.get('file_id') or not headers.get('Verification-Token'): + return {'message': 'Missing required parameters.'}, HTTPStatus.BAD_REQUEST + access_request = cast( + dict[str, Any], + PublicObjectAccessRequestSchema().load({ + 'file_id': args.get('file_id'), + 'verification_token': headers.get('Verification-Token'), + }), + ) + verification, survey, _ = _get_public_upload_scope( + access_request['verification_token']) + + object_storage_service = ObjectStorageService() + object_key = object_storage_service.get_object_key( + access_request['file_id']) + expected_prefix = object_storage_service.build_public_upload_prefix( + tenant_id=survey.tenant_id, + survey_id=survey.id, + verification_id=verification['id'], + ) + if not object_key.startswith(expected_prefix): + return {'message': 'Invalid file requested.'}, HTTPStatus.FORBIDDEN + + object_storage_service.delete_file(object_key) + return {}, HTTPStatus.NO_CONTENT + except KeyError as err: + return str(err), HTTPStatus.BAD_REQUEST + except PermissionError as err: + return {'message': str(err)}, HTTPStatus.FORBIDDEN + except ValueError as err: + return {'message': str(err)}, HTTPStatus.BAD_REQUEST diff --git a/api/src/api/resources/survey.py b/api/src/api/resources/survey.py index 4850aef9c..efa98e54a 100644 --- a/api/src/api/resources/survey.py +++ b/api/src/api/resources/survey.py @@ -87,14 +87,21 @@ def get(): exclude_hidden=args.get('exclude_hidden', False, bool), exclude_template=args.get('exclude_template', False, bool), search_text=args.get('search_text', '', str), - is_unlinked=args.get('is_unlinked', default=False, type=lambda v: v.lower() == 'true'), - is_linked=args.get('is_linked', default=False, type=lambda v: v.lower() == 'true'), - is_hidden=args.get('is_hidden', default=False, type=lambda v: v.lower() == 'true'), - is_template=args.get('is_template', default=False, type=lambda v: v.lower() == 'true'), - created_date_from=args.get('created_date_from', None, type=str), + is_unlinked=args.get( + 'is_unlinked', default=False, type=lambda v: v.lower() == 'true'), + is_linked=args.get('is_linked', default=False, + type=lambda v: v.lower() == 'true'), + is_hidden=args.get('is_hidden', default=False, + type=lambda v: v.lower() == 'true'), + is_template=args.get( + 'is_template', default=False, type=lambda v: v.lower() == 'true'), + created_date_from=args.get( + 'created_date_from', None, type=str), created_date_to=args.get('created_date_to', None, type=str), - published_date_from=args.get('published_date_from', None, type=str), - published_date_to=args.get('published_date_to', None, type=str), + published_date_from=args.get( + 'published_date_from', None, type=str), + published_date_to=args.get( + 'published_date_to', None, type=str), ) survey_records = SurveyService()\ diff --git a/api/src/api/schemas/public_upload.py b/api/src/api/schemas/public_upload.py new file mode 100644 index 000000000..2b6b52a78 --- /dev/null +++ b/api/src/api/schemas/public_upload.py @@ -0,0 +1,53 @@ +"""Schemas for public object-storage upload authorization.""" + +from marshmallow import EXCLUDE, Schema, fields, validate + + +class PublicUploadAuthorizationRequestSchema(Schema): + """Request schema for token-scoped public upload authorization.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + filename = fields.Str( + data_key='filename', + required=True, + validate=validate.Length(min=1, max=2048), + ) + content_type = fields.Str( + data_key='content_type', + required=True, + validate=validate.Length(min=1, max=255), + ) + size = fields.Int( + data_key='size', + required=True, + validate=validate.Range(min=1), + ) + verification_token = fields.Str( + data_key='verification_token', + required=True, + validate=validate.Length(min=1, max=255), + ) + + +class PublicObjectAccessRequestSchema(Schema): + """Request schema for token-scoped access to a previously uploaded object.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + file_id = fields.Str( + data_key='file_id', + required=True, + validate=validate.Length(min=1, max=4096), + ) + verification_token = fields.Str( + data_key='verification_token', + required=True, + validate=validate.Length(min=1, max=255), + ) diff --git a/api/src/api/services/authorization.py b/api/src/api/services/authorization.py index 2cc11c965..6ae27b8c4 100644 --- a/api/src/api/services/authorization.py +++ b/api/src/api/services/authorization.py @@ -2,10 +2,9 @@ This module is to handle authorization related queries. """ -from http import HTTPStatus - from flask import current_app, g -from flask_restx import abort +from flask_restx import abort as flask_abort +from flask_restx._http import HTTPStatus from api.constants.membership_type import MembershipType from api.models.engagement import Engagement as EngagementModel @@ -22,8 +21,22 @@ # pylint: disable=unused-argument @add_user_context -def check_auth(**kwargs): +def check_auth(**kwargs) -> bool: """Check if user is authorized to perform action on the service.""" + + def abort( + status_code: HTTPStatus = HTTPStatus.FORBIDDEN, + message: str = UNAUTHORIZED_MSG, + ): + """ + Abort with a given status code and message. + + Return false instead of aborting if 'abort' is set to False in kwargs. + """ + if kwargs.get('abort', True): + flask_abort(status_code, message) + return False + skip_tenant_check = current_app.config.get('IS_SINGLE_TENANT_ENVIRONMENT') user_from_context: UserContext = kwargs['user_context'] user_from_db = StaffUserModel.get_user_by_external_id( @@ -36,28 +49,29 @@ def check_auth(**kwargs): user_from_context.token_info) if not user_roles: - abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) + abort() if Role.SUPER_ADMIN.value in user_roles: - return # Let Super Admins do anything they want :3 + return True # Let Super Admins do anything they want :3 required_roles = set(kwargs.get('one_of_roles', [])) has_valid_roles = set(user_roles) & required_roles if has_valid_roles: if skip_tenant_check: - return + return True if 'engagement_id' in kwargs: _check_engagement_has_tenant( kwargs.get('engagement_id'), g.tenant_id) - return + return True membership_eligible_roles = {MembershipType.TEAM_MEMBER.name, MembershipType.REVIEWER.name } & required_roles # check if the user is a member of a passed engagement if membership_eligible_roles and _has_team_membership(kwargs, user_from_context, membership_eligible_roles): - return + return True - abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) + abort() + return False @add_user_context @@ -115,7 +129,7 @@ def _check_engagement_has_tenant(eng_id, tenant_id): f'engagement_tenant_id:{engagement_tenant_id}\n' f'tenant_id: {tenant_id}') - abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) + flask_abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) def _has_team_membership(kwargs, user_from_context, team_permitted_roles) -> bool: @@ -145,6 +159,6 @@ def _has_team_membership(kwargs, user_from_context, team_permitted_roles) -> boo current_app.logger.debug(f'Aborting . Tenant Id on membership and user context Mismatch' f'membership.tenant_id:{membership.tenant_id} ' f'user_from_context.tenant_id: {g.tenant_id}') - abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) + flask_abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) return membership.type.name in team_permitted_roles diff --git a/api/src/api/services/comment_service.py b/api/src/api/services/comment_service.py index 81ddc61dc..a74fe90a7 100644 --- a/api/src/api/services/comment_service.py +++ b/api/src/api/services/comment_service.py @@ -54,8 +54,9 @@ def can_view_unapproved_comments(survey_id: int) -> bool: return False user_roles = TokenInfo.get_user_roles() - if Role.REVIEW_COMMENTS.value in user_roles: - return True + for approved_role in [Role.REVIEW_ALL_COMMENTS.value, Role.SUPER_ADMIN.value]: + if approved_role in user_roles: + return True engagement = SurveyModel.find_by_id(survey_id) if not engagement: @@ -267,7 +268,8 @@ def group_comments_by_submission_id(cls, comments): grouped_comments = [] for comment in comments: submission_id = comment['submission_id'] - text = comment.get('text', '') # Get the text, or an empty string if it's missing + # Get the text, or an empty string if it's missing + text = comment.get('text', '') label = comment['label'] # Check if a group with the same submission ID already exists diff --git a/api/src/api/services/object_storage_service.py b/api/src/api/services/object_storage_service.py index 965ce2852..2d30ce7f2 100644 --- a/api/src/api/services/object_storage_service.py +++ b/api/src/api/services/object_storage_service.py @@ -1,14 +1,13 @@ """Service for object storage management.""" import os import uuid -from typing import List +from typing import Any, List, Union from urllib.parse import urlparse import requests from aws_requests_auth.aws_auth import AWSRequestsAuth from api.config import Config -from api.schemas.document import Document class ObjectStorageService: @@ -52,28 +51,94 @@ def get_object_key(self, filename: str): return filename.lstrip('/') - def get_auth_headers(self, documents: List[Document]): + def delete_file(self, file_path: str): + """Delete a file from the object storage service.""" + if not file_path: + return + object_key = self.get_object_key(file_path) + if not object_key: + return + s3uri = f'https://{self.s3_auth.aws_host}/{self.s3_bucket}/{object_key}' + requests.delete(s3uri, auth=self.s3_auth, timeout=None) + + def is_configured(self): + """Return whether object storage configuration is present.""" + return ( + self.s3_auth.aws_access_key is not None and + self.s3_auth.aws_secret_access_key is not None and + self.s3_auth.aws_host is not None and + self.s3_bucket is not None + ) + + def generate_unique_filename(self, filename: str): + """Return a unique filename preserving the original extension.""" + filenamesplittext = os.path.splitext(filename) + return f'{uuid.uuid4()}{filenamesplittext[1]}' + + def build_public_upload_key(self, tenant_id: int, survey_id: int, verification_id: int, filename: str): + """Build an object key for a public survey upload.""" + unique_filename = self.generate_unique_filename(filename) + object_key = ( + f'tenant_{tenant_id}/survey_{survey_id}/' + f'verification_{verification_id}/{unique_filename}' + ) + return object_key, unique_filename + + @staticmethod + def build_public_upload_prefix(tenant_id: int, survey_id: int, verification_id: int): + """Build the expected object key prefix for a public survey upload.""" + return f'tenant_{tenant_id}/survey_{survey_id}/verification_{verification_id}/' + + def get_signed_request_details(self, method: str, object_key: str, content_type: Union[str, None] = None): + """Return signed request headers for a single object request without executing it.""" + if not self.is_configured(): + raise ValueError( + '(get_signed_request_details) S3 Object service is not configured properly.' + ) + + s3uri = self.get_url(object_key) + if not s3uri: + raise ValueError('Invalid object key for object storage upload.') + + headers = {} + if content_type: + headers['Content-Type'] = content_type + + prepared_request = requests.Request( + method, s3uri, headers=headers).prepare() + signed_request = self.s3_auth(prepared_request) + + return { + 'filepath': s3uri, + 'authheader': signed_request.headers['Authorization'], + 'amzdate': signed_request.headers['x-amz-date'], + } + + def get_signed_upload_details(self, object_key: str, content_type: Union[str, None] = None): + """Return signed request headers for a single object PUT without uploading it.""" + return self.get_signed_request_details('PUT', object_key, content_type) + + def get_auth_headers(self, documents: List[dict[str, Any]]): """Get the S3 auth headers for the provided documents.""" - if ( - self.s3_auth.aws_access_key is None or - self.s3_auth.aws_secret_access_key is None or - self.s3_auth.aws_host is None or - self.s3_bucket is None - ): + if not self.is_configured(): return { 'status': 'Configuration Issue', - 'message': 'accesskey is None or secretkey is None or S3 host is None or formsbucket is None' + 'message': '(get_auth_headers) S3 Object service is not configured properly.', }, 500 for file in documents: s3sourceuri = file.get('s3sourceuri', None) - filenamesplittext = os.path.splitext(file.get('filename')) - uniquefilename = f'{uuid.uuid4()}{filenamesplittext[1]}' + filename = file.get('filename') + if not filename: + raise ValueError('filename is required') + uniquefilename = self.generate_unique_filename(filename) - s3uri = s3sourceuri if s3sourceuri is not None else self.get_url(uniquefilename) + s3uri = s3sourceuri if s3sourceuri is not None else self.get_url( + uniquefilename) if s3sourceuri is None: - response = requests.put(s3uri, data=None, auth=self.s3_auth, timeout=None) + response = requests.put( + s3uri, data=None, auth=self.s3_auth, timeout=None) else: response = requests.get(s3uri, auth=self.s3_auth, timeout=None) diff --git a/api/src/api/services/survey_service.py b/api/src/api/services/survey_service.py index 61ecf910e..f3c964a42 100644 --- a/api/src/api/services/survey_service.py +++ b/api/src/api/services/survey_service.py @@ -1,5 +1,6 @@ """Service for survey management.""" -from http import HTTPStatus +from typing import List + from sqlalchemy.exc import SQLAlchemyError from api.constants.engagement_status import Status @@ -18,8 +19,6 @@ from api.services.object_storage_service import ObjectStorageService from api.services.report_setting_service import ReportSettingService from api.utils.roles import Role -from api.utils.token_info import TokenInfo -from ..exceptions.business_exception import BusinessException class SurveyService: @@ -30,7 +29,7 @@ class SurveyService: @classmethod def get(cls, survey_id): - """Get survey by the ID.""" + """Get a survey by its ID.""" survey_model = SurveyModel.find_by_id(survey_id) eng_id = None one_of_roles = (Role.VIEW_SURVEYS.value,) @@ -40,7 +39,8 @@ def get(cls, survey_id): # Only Admins can view hidden surveys. one_of_roles = (Role.VIEW_ALL_SURVEYS.value,) elif survey_model.engagement_id: - engagement_model = EngagementModel.find_by_id(survey_model.engagement_id) + engagement_model = EngagementModel.find_by_id( + survey_model.engagement_id) if engagement_model: eng_id = engagement_model.id if engagement_model.status_id == Status.Published.value: @@ -54,36 +54,39 @@ def get(cls, survey_id): ) if not skip_auth: - authorization.check_auth(one_of_roles=one_of_roles, engagement_id=eng_id) + authorization.check_auth( + one_of_roles=one_of_roles, engagement_id=eng_id) survey = SurveySchema().dump(survey_model) return survey @classmethod def get_open(cls, survey_id): - """Get survey by the id.""" + """Get a survey by its ID and ensure it is open for public access.""" survey_model = SurveyModel.get_open(survey_id) - engagement_model: EngagementModel = EngagementModel.find_by_id(survey_model.engagement_id) + engagement_model: EngagementModel = EngagementModel.find_by_id( + survey_model.engagement_id) survey = SurveySchema().dump(survey_model) eng = EngagementSchema().dump(engagement_model) - eng['banner_url'] = ObjectStorageService().get_url(engagement_model.banner_filename) + eng['banner_url'] = ObjectStorageService().get_url( + engagement_model.banner_filename) survey['engagement'] = eng return survey @staticmethod def get_surveys_paginated(user_id, pagination_options: PaginationOptions, search_options: SurveySearchOptions): - """Get engagements paginated.""" + """Get a paginated list of surveys.""" # check if user has view all surveys access to view hidden surveys as well - user_roles = TokenInfo.get_user_roles() - can_view_all_surveys = SurveyService._can_view_all_surveys(user_roles) + can_view_all_surveys = SurveyService._can_view_all_surveys() if not can_view_all_surveys: search_options.exclude_hidden = True - search_options.assigned_engagements = SurveyService._get_assigned_engagements(user_id, user_roles) + search_options.assigned_engagements = SurveyService._get_assigned_engagements( + user_id) # check if user can view surveys linked to unassigned engagement - search_options.can_view_all_engagements = SurveyService._can_view_all_engagements(user_roles) + search_options.can_view_all_engagements = SurveyService._can_view_all_engagements() items, total = SurveyModel.get_surveys_paginated( pagination_options, @@ -97,24 +100,27 @@ def get_surveys_paginated(user_id, pagination_options: PaginationOptions, search } @staticmethod - def _get_assigned_engagements(user_id, user_roles): - if Role.VIEW_PRIVATE_ENGAGEMENTS.value in user_roles: - return None + def _get_assigned_engagements(user_id): + if not authorization.check_auth(one_of_roles=[ + Role.VIEW_PRIVATE_ENGAGEMENTS.value + ], abort=False): + empty_list: List[int] = [] + return empty_list memberships = MembershipService.get_assigned_engagements(user_id) - return [membership.engagement_id for membership in memberships] + return [int(membership.engagement_id) for membership in memberships] @staticmethod - def _can_view_all_engagements(user_roles): - if Role.VIEW_ENGAGEMENT.value in user_roles: - return True - return False + def _can_view_all_engagements(): + return authorization.check_auth(one_of_roles=[ + Role.VIEW_ENGAGEMENT.value + ], abort=False) @staticmethod - def _can_view_all_surveys(user_roles): + def _can_view_all_surveys(): """Return false if user does not have access to view all hidden surveys.""" - if Role.VIEW_ALL_SURVEYS.value in user_roles: - return True - return False + return authorization.check_auth(one_of_roles=[ + Role.VIEW_ALL_SURVEYS.value + ], abort=False) @classmethod def create(cls, survey_data: dict): @@ -176,9 +182,9 @@ def update(cls, data: SurveySchema): Role.EDIT_SURVEY.value), engagement_id=engagement_id) # check if user has edit all surveys access to edit template surveys as well - user_roles = TokenInfo.get_user_roles() + is_template = survey.get('is_template', None) - cls.validate_template_surveys_edit_access(is_template, user_roles) + cls.validate_template_surveys_edit_access(is_template) if engagement and engagement.get('status_id', None) not in [ Status.Draft.value, @@ -199,25 +205,20 @@ def update(cls, data: SurveySchema): @staticmethod def validate_update_fields(data): """Validate all fields.""" - empty_fields = [not data[field] for field in ['id']] - - if any(empty_fields): + if any(not data[field] for field in ['id']): raise ValueError('Some required fields are empty') @staticmethod - def validate_template_surveys_edit_access(is_template, user_roles): - """Validatef user has edit access on a template survey.""" - if is_template and Role.EDIT_ALL_SURVEYS.value not in user_roles: - raise BusinessException( - error='Changes could not be saved due to restricted access on a template survey', - status_code=HTTPStatus.FORBIDDEN) + def validate_template_surveys_edit_access(is_template): + """Validate user has edit access on a template survey.""" + if is_template: + authorization.check_auth( + one_of_roles=[Role.EDIT_ALL_SURVEYS.value]) @staticmethod def validate_create_fields(data): """Validate all fields.""" - empty_fields = [not data[field] for field in ['name', 'display']] - - if any(empty_fields): + if any(not data[field] for field in ['name', 'display']): raise ValueError('Some required fields are empty') @classmethod @@ -231,14 +232,14 @@ def link(cls, survey_id, engagement_id): @classmethod def validate_link_fields(cls, survey_id, engagement_id): """Validate all fields.""" - empty_fields = [not value for value in [survey_id, engagement_id]] - if any(empty_fields): - raise ValueError('Necessary fields for linking survey to an engagement were missing') + if any(not value for value in [survey_id, engagement_id]): + raise ValueError( + 'Necessary fields for linking survey to an engagement were missing') survey = cls.get(survey_id) if not survey: - raise ValueError('Could not find survey ' + survey_id) + raise ValueError('Could not find survey ' + str(survey_id)) if survey.get('engagement', None): raise ValueError('Survey is already linked to an engagement') @@ -254,22 +255,24 @@ def unlink(cls, survey_id, engagement_id): @classmethod def validate_unlink_fields(cls, survey_id, engagement_id): """Validate all fields for unlinking survey.""" - empty_fields = [not value for value in [survey_id, engagement_id]] - if any(empty_fields): - raise ValueError('Necessary fields for unlinking survey to an engagement were missing') + if any(not value for value in [survey_id, engagement_id]): + raise ValueError( + 'Necessary fields for unlinking survey to an engagement were missing') survey = cls.get(survey_id) if not survey: - raise ValueError('Could not find survey ' + survey_id) + raise ValueError('Could not find survey ' + str(survey_id)) linked_engagement = survey.get('engagement', None) if not linked_engagement or linked_engagement.get('id') != int(engagement_id): - raise ValueError('Survey is not linked to engagement ' + engagement_id) + raise ValueError( + 'Survey is not linked to engagement ' + str(engagement_id)) engagement_status = linked_engagement.get('engagement_status') if engagement_status.get('id') != Status.Draft.value: - raise ValueError('Cannot unlink survey from engagement with status ' + engagement_status.get('status_name')) + raise ValueError( + 'Cannot unlink survey from engagement with status ' + engagement_status.get('status_name')) @classmethod def delete(cls, survey_id: int): @@ -282,7 +285,8 @@ def delete(cls, survey_id: int): raise ValueError('Survey not found') try: - SurveyService._verify_linked_engagement_status(survey.engagement_id) + SurveyService._verify_linked_engagement_status( + survey.engagement_id) for tx in (SurveyTranslation.get_survey_translation_list_by_survey_id(survey_id) or []): SurveyTranslation.delete_survey_translation(tx.id) @@ -302,4 +306,5 @@ def _verify_linked_engagement_status(engagement_id): if not engagement: raise ValueError('Linked engagement not found') if engagement.status_id == Status.Published.value: - raise ValueError('Cannot delete a survey that is linked to a published engagement') + raise ValueError( + 'Cannot delete a survey that is linked to a published engagement') diff --git a/api/src/api/utils/token_info.py b/api/src/api/utils/token_info.py index 28274970c..8e86ad409 100644 --- a/api/src/api/utils/token_info.py +++ b/api/src/api/utils/token_info.py @@ -38,6 +38,7 @@ def get_user_roles(): """Get the user roles from token.""" if not hasattr(g, 'jwt_oidc_token_info') or not g.jwt_oidc_token_info: return [] - valid_roles = set(item.value for item in Role) - token_roles = current_app.config['JWT_ROLE_CALLBACK'](g.jwt_oidc_token_info) + valid_roles = {item.value for item in Role} + token_roles = current_app.config['JWT_ROLE_CALLBACK']( + g.jwt_oidc_token_info) return valid_roles.intersection(token_roles) diff --git a/api/tests/unit/api/test_survey.py b/api/tests/unit/api/test_survey.py index f342d49b0..e35104590 100644 --- a/api/tests/unit/api/test_survey.py +++ b/api/tests/unit/api/test_survey.py @@ -218,7 +218,8 @@ def test_survey_link(client, jwt, session, side_effect, expected_status, # test if public user can fetch open surveys rv = client.get( f'{surveys_url}{survey_id}', - headers=factory_auth_header(jwt=jwt, claims=TestJwtClaims.public_user_role), + headers=factory_auth_header( + jwt=jwt, claims=TestJwtClaims.public_user_role), content_type=ContentType.JSON.value ) assert rv.json.get('engagement_id') == str(eng_id) @@ -244,12 +245,36 @@ def test_get_hidden_survey_for_admins(client, jwt, session, assert rv.json.get('total') == 1 +def test_get_surveys_for_super_admin_includes_draft_engagements( + client, jwt, session, setup_super_admin_user_and_claims +): # pylint:disable=unused-argument + """Assert that super admins can list surveys linked to draft engagements.""" + set_global_tenant() + _, claims = setup_super_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + + engagement = factory_engagement_model(status=Status.Draft.value) + factory_survey_model( + {**TestSurveyInfo.survey1, 'engagement_id': engagement.id}) + + rv = client.get( + f'{surveys_url}?page=1&size=10&sort_key=survey.created_date&sort_order=desc', + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.OK.value + assert rv.json.get('total') == 1 + assert len(rv.json.get('items', [])) == 1 + + def test_get_survey_for_reviewer(client, jwt, session): # pylint:disable=unused-argument """Assert reviewers different permission.""" set_global_tenant() staff_1 = dict(TestUserInfo.user_staff_1) user = factory_staff_user_model(user_info=staff_1) - factory_user_group_membership_model(str(user.external_id), group_id=CompositeRoleId.REVIEWER.value) + factory_user_group_membership_model( + str(user.external_id), group_id=CompositeRoleId.REVIEWER.value) claims = copy.deepcopy(TestJwtClaims.reviewer_role.value) claims['sub'] = str(user.external_id) headers = factory_auth_header(jwt=jwt, claims=claims) @@ -271,7 +296,8 @@ def test_get_survey_for_reviewer(client, jwt, session): # pylint:disable=unused assert rv.status_code == 403 # Add user as a reviewer in the team - factory_membership_model(user_id=user.id, engagement_id=eng.id, member_type='REVIEWER') + factory_membership_model( + user_id=user.id, engagement_id=eng.id, member_type='REVIEWER') # Assert Reviewer can see the survey since he is added to the team. rv = client.get(f'{surveys_url}{survey1.id}', @@ -279,7 +305,8 @@ def test_get_survey_for_reviewer(client, jwt, session): # pylint:disable=unused assert rv.status_code == HTTPStatus.OK.value # Deactivate membership - membership_model: MembershipModel = MembershipModel.find_by_engagement_and_user_id(eng.id, user.id) + membership_model: MembershipModel = MembershipModel.find_by_engagement_and_user_id( + eng.id, user.id) membership_model.status = MembershipStatus.INACTIVE.value membership_model.commit() @@ -499,7 +526,8 @@ def test_delete_survey(client, jwt, session, mocker, survey = factory_survey_model() survey_id = str(survey.id) - mocker.patch('api.services.survey_service.SurveyService.delete', return_value=None) + mocker.patch( + 'api.services.survey_service.SurveyService.delete', return_value=None) rv = client.delete( f'{surveys_url}{survey_id}/delete', headers=headers, @@ -508,7 +536,8 @@ def test_delete_survey(client, jwt, session, mocker, assert rv.status_code == HTTPStatus.OK assert rv.get_json() == {'id': survey_id} - mocker.patch('api.services.survey_service.SurveyService.delete', side_effect=KeyError('boom')) + mocker.patch('api.services.survey_service.SurveyService.delete', + side_effect=KeyError('boom')) rv = client.delete( f'{surveys_url}{survey_id}/delete', headers=headers, @@ -516,7 +545,8 @@ def test_delete_survey(client, jwt, session, mocker, ) assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - mocker.patch('api.services.survey_service.SurveyService.delete', side_effect=ValueError('bad')) + mocker.patch('api.services.survey_service.SurveyService.delete', + side_effect=ValueError('bad')) rv = client.delete( f'{surveys_url}{survey_id}/delete', headers=headers, diff --git a/web/src/apiManager/endpoints/index.ts b/web/src/apiManager/endpoints/index.ts index 4ccd5aa08..426d5b69c 100644 --- a/web/src/apiManager/endpoints/index.ts +++ b/web/src/apiManager/endpoints/index.ts @@ -59,6 +59,7 @@ const Endpoints = { }, Document: { OSS_HEADER: `${AppConfig.apiUrl}/document/`, + PUBLIC: `${AppConfig.apiUrl}/document/public`, }, Survey: { GET_LIST: `${AppConfig.apiUrl}/surveys/`, diff --git a/web/src/components/Form/FormBuilder.tsx b/web/src/components/Form/FormBuilder.tsx index 0110b06c3..cbb88e430 100644 --- a/web/src/components/Form/FormBuilder.tsx +++ b/web/src/components/Form/FormBuilder.tsx @@ -2,13 +2,20 @@ import React from 'react'; import { FormBuilder as FormioFormBuilder } from './formio/setup'; import { formioOptions } from './FormBuilderOptions'; import { FormBuilderData, FormBuilderProps } from './types'; +import { createSimpleFileOptions } from './formio/simpleFileOptions'; const FormBuilder = ({ handleFormChange, savedForm }: FormBuilderProps) => { + // Add file upload and other handlers for Formio to call when uploading files. + const fileUploadOptions = createSimpleFileOptions(); + return (
handleFormChange(form as FormBuilderData)} /> diff --git a/web/src/components/Form/FormBuilderOptions.ts b/web/src/components/Form/FormBuilderOptions.ts index 5eef5385a..d7b6678d7 100644 --- a/web/src/components/Form/FormBuilderOptions.ts +++ b/web/src/components/Form/FormBuilderOptions.ts @@ -16,8 +16,8 @@ export const formioOptions = { // simpletextarea: true, // overridden // simpleradios: true, // overridden simplecheckboxes: true, - header: true, - paragraph: true, + header: false, // redundant + paragraph: false, // redundant simplepostalcode: true, // hiding category checkboxes categorycheckboxes: false, diff --git a/web/src/components/Form/FormSubmit.tsx b/web/src/components/Form/FormSubmit.tsx index f8c26ada7..6c0ca4869 100644 --- a/web/src/components/Form/FormSubmit.tsx +++ b/web/src/components/Form/FormSubmit.tsx @@ -3,13 +3,23 @@ import { FormSubmitterProps } from './types'; import SinglePageForm from './SinglePageForm'; import MultiPageForm from './MultiPageForm'; -const FormSubmit = ({ handleFormChange, savedForm, handleFormSubmit }: FormSubmitterProps) => { - const isMultiPage = savedForm && savedForm.display === 'wizard'; +const FormSubmit = ({ + handleFormChange, + handleFormCancel, + savedForm, + handleFormSubmit, + verificationToken, +}: FormSubmitterProps) => { + const FormComponent = savedForm?.display === 'wizard' ? MultiPageForm : SinglePageForm; - return isMultiPage ? ( - - ) : ( - + return ( + ); }; diff --git a/web/src/components/Form/MultiPageForm.tsx b/web/src/components/Form/MultiPageForm.tsx index 570a82b2f..bb73c3425 100644 --- a/web/src/components/Form/MultiPageForm.tsx +++ b/web/src/components/Form/MultiPageForm.tsx @@ -2,15 +2,22 @@ import React, { useState } from 'react'; import { Form } from './formio/setup'; import { FormSubmissionData, FormSubmitterProps } from './types'; import FormStepper from 'components/survey/submit/Stepper'; +import { createSimpleFileOptions } from './formio/simpleFileOptions'; interface PageData { page: number; submission: unknown; } -const MultiPageForm = ({ handleFormChange, savedForm, handleFormSubmit }: FormSubmitterProps) => { +const MultiPageForm = ({ + handleFormChange, + handleFormCancel, + savedForm, + handleFormSubmit, + verificationToken, +}: FormSubmitterProps) => { const [currentPage, setCurrentPage] = useState(0); - + const simpleFileOptions = createSimpleFileOptions({ verificationToken }); const handleScrollUp = () => { globalThis.scrollTo({ top: 100, @@ -23,7 +30,10 @@ const MultiPageForm = ({ handleFormChange, savedForm, handleFormSubmit }: FormSu
handleFormChange(form as FormSubmissionData)} onNextPage={(pageData: PageData) => { setCurrentPage(pageData.page); @@ -33,10 +43,8 @@ const MultiPageForm = ({ handleFormChange, savedForm, handleFormSubmit }: FormSu setCurrentPage(pageData.page); handleScrollUp(); }} - onSubmit={(form: unknown) => { - const formSubmissionData = form as FormSubmissionData; - handleFormSubmit(formSubmissionData.data); - }} + onCancel={() => handleFormCancel?.()} + onSubmit={(form: unknown) => handleFormSubmit?.((form as FormSubmissionData).data)} />
); diff --git a/web/src/components/Form/SinglePageForm.tsx b/web/src/components/Form/SinglePageForm.tsx index 3cb021574..f8ec62c7d 100644 --- a/web/src/components/Form/SinglePageForm.tsx +++ b/web/src/components/Form/SinglePageForm.tsx @@ -1,17 +1,27 @@ import React from 'react'; import { Form } from './formio/setup'; import { FormSubmissionData, FormSubmitterProps } from './types'; +import { createSimpleFileOptions } from './formio/simpleFileOptions'; + +const SinglePageForm = ({ + handleFormChange, + handleFormCancel, + savedForm, + handleFormSubmit, + verificationToken, +}: FormSubmitterProps) => { + const simpleFileOptions = createSimpleFileOptions({ verificationToken }); -const SinglePageForm = ({ handleFormChange, savedForm, handleFormSubmit }: FormSubmitterProps) => { return (
handleFormChange(form as FormSubmissionData)} - onSubmit={(form: unknown) => { - const formSubmissionData = form as FormSubmissionData; - handleFormSubmit(formSubmissionData.data); + options={{ + ...simpleFileOptions, }} + onCancel={() => handleFormCancel?.()} + onChange={(form: unknown) => handleFormChange(form as FormSubmissionData)} + onSubmit={(form: unknown) => handleFormSubmit?.((form as FormSubmissionData).data)} />
); diff --git a/web/src/components/Form/formio/simpleFileOptions.ts b/web/src/components/Form/formio/simpleFileOptions.ts new file mode 100644 index 000000000..57d3939fd --- /dev/null +++ b/web/src/components/Form/formio/simpleFileOptions.ts @@ -0,0 +1,76 @@ +import { AxiosRequestConfig } from 'axios'; +import { deletePublicObject, downloadPublicObject, savePublicObject } from 'services/objectStorageService'; + +type UploadConfig = AxiosRequestConfig & { + onUploadProgress?: (event: ProgressEvent) => void; +}; + +interface SimpleFileOptionsConfig { + verificationToken?: string; +} + +const createPublicUploadResponse = ( + filepath: string, + file: File, + uniqueFileName: string, + contentType?: string, + size?: number, +) => ({ + data: { + id: filepath, + originalname: file.name, + mimetype: contentType ?? file.type, + name: uniqueFileName, + size: size ?? file.size, + }, +}); + +export const createSimpleFileOptions = ({ verificationToken }: SimpleFileOptionsConfig = {}) => ({ + componentOptions: { + simplefile: { + fileService: 'objectStorage', + objectStorage: { + endpoint: 'https://citz-gdx.objectstore.gov.bc.ca', + bucket: 'engagement-dev-uploads', + }, + uploadFile: async (formData: FormData, config?: UploadConfig) => { + if (!verificationToken) { + throw new Error('Verification token is required for public file uploads.'); + } + + const file = formData.get('file') ?? formData.get('files'); + if (!(file instanceof File)) { + throw new TypeError('A valid file upload payload is required.'); + } + + const response = await savePublicObject(file, verificationToken, config); + return createPublicUploadResponse( + response.filepath, + file, + response.uniquefilename, + response.content_type, + response.size, + ); + }, + getFile: async (fileId: string, _config?: AxiosRequestConfig) => { + if (!verificationToken) { + throw new Error('Verification token is required for public file downloads.'); + } + + await downloadPublicObject(fileId, verificationToken); + }, + deleteFile: async (fileInfo: { data?: { id?: string }; id?: string }, _config?: AxiosRequestConfig) => { + if (!verificationToken) { + throw new Error('Verification token is required for public file deletion.'); + } + + const fileId = fileInfo.data?.id ?? fileInfo.id; + if (!fileId) { + return; + } + + await deletePublicObject(fileId, verificationToken); + }, + }, + }, +}); diff --git a/web/src/components/Form/types.ts b/web/src/components/Form/types.ts index 131ee91eb..671eaed2a 100644 --- a/web/src/components/Form/types.ts +++ b/web/src/components/Form/types.ts @@ -1,7 +1,9 @@ export interface FormSubmitterProps { handleFormChange: (form: FormSubmissionData) => void; - handleFormSubmit: (data: unknown) => void; + handleFormSubmit?: (data: unknown) => void; + handleFormCancel?: () => void; savedForm?: FormBuilderData; + verificationToken?: string; } export interface FormBuilderProps { diff --git a/web/src/components/comments/admin/review/CommentReview.tsx b/web/src/components/comments/admin/review/CommentReview.tsx index 7ce2dfca6..5f917cb13 100644 --- a/web/src/components/comments/admin/review/CommentReview.tsx +++ b/web/src/components/comments/admin/review/CommentReview.tsx @@ -54,7 +54,7 @@ const CommentReview = () => { const [notifyEmail, setNotifyEmail] = useState(true); const [staffNote, setStaffNote] = useState([]); const [updatedStaffNote, setUpdatedStaffNote] = useState([]); - const [openEmailPreview, setEmailPreview] = useState(false); + const [emailPreviewOpen, setEmailPreviewOpen] = useState(false); const [survey, setSurvey] = useState(createDefaultSurvey()); const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -86,8 +86,8 @@ const CommentReview = () => { const fetchSubmission = async () => { try { - if (isNaN(Number(submissionId))) { - throw new Error(); + if (Number.isNaN(Number(submissionId))) { + throw new Error('Invalid submission ID'); } const fetchedSubmission = await getSubmission(Number(submissionId)); const fetchedSurvey = await getSurvey(Number(surveyId)); @@ -178,7 +178,7 @@ const CommentReview = () => { }; const previewEmail = () => { - setEmailPreview(true); + setEmailPreviewOpen(true); }; // The comment display information below is fetched from the first comment from the list @@ -206,8 +206,8 @@ const CommentReview = () => { return ( setEmailPreview(false)} + open={emailPreviewOpen} + handleClose={() => setEmailPreviewOpen(false)} header={'Your comment on (Engagement name) needs to be edited'} renderEmail={getEmailPreview()} /> diff --git a/web/src/components/common/Navigation/Breadcrumb.tsx b/web/src/components/common/Navigation/Breadcrumb.tsx index bb3ed114f..5eaad1058 100644 --- a/web/src/components/common/Navigation/Breadcrumb.tsx +++ b/web/src/components/common/Navigation/Breadcrumb.tsx @@ -1,5 +1,5 @@ import { Breadcrumbs } from '@mui/material'; -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { BodyText } from '../Typography'; import { Link } from '.'; import { UIMatch, useMatches } from 'react-router'; @@ -81,7 +81,6 @@ export interface UIMatchWithCrumb extends UIMatch {} export const AutoBreadcrumbs: React.FC<{ smallScreenOnly?: boolean }> = ({ smallScreenOnly }) => { const matches = (useMatches() as UIMatchWithCrumb[]).filter((match) => match.handle?.crumb); const matchKey = matches.map((m) => m.pathname).join('|'); - const [resolvedCrumbs, setResolvedCrumbs] = React.useState>({}); const crumbs = useMemo(() => { return matches.map((match) => { @@ -96,40 +95,20 @@ export const AutoBreadcrumbs: React.FC<{ smallScreenOnly?: boolean }> = ({ small }); }, [matchKey]); // Recompute only when matches change - useEffect(() => { - let cancelled = false; - - const setNewCrumbs = ( - resolvedCrumb: BreadcrumbProps, - previousCrumbs: Record, - pathname: string, - ) => { - const previousCrumb = previousCrumbs[pathname]; - - // Avoid unnecessary re-renders if the crumb did not actually change. - if (previousCrumb?.name === resolvedCrumb?.name && previousCrumb?.link === resolvedCrumb?.link) { - return previousCrumbs; - } - - return { - ...previousCrumbs, - [pathname]: resolvedCrumb, - }; - }; - - crumbs.forEach(async (unresolvedCrumb, index) => { - const pathname = matches[index]?.pathname; - if (!pathname) return; - - const resolvedCrumb = await unresolvedCrumb; - if (cancelled) return; - setResolvedCrumbs((previousCrumbs) => setNewCrumbs(resolvedCrumb, previousCrumbs, pathname)); - }); - - return () => { - cancelled = true; - }; - }, [crumbs, matches]); + const resolvedCrumbs = crumbs.map((unresolvedCrumb) => { + if (unresolvedCrumb instanceof Promise) { + return React.use(unresolvedCrumb); + } + return unresolvedCrumb; + }); + + const crumbMap = Object.create(null); + resolvedCrumbs.forEach((crumb, index) => { + const pathname = matches[index]?.pathname; + if (pathname) { + crumbMap[pathname] = crumb; + } + }); return ( = ({ small }} > {matches.map((match, index) => { - const resolvedCrumb = resolvedCrumbs[match.pathname]; + const resolvedCrumb = crumbMap[match.pathname]; if (!resolvedCrumb) return null; const name = resolvedCrumb?.name; const link = index < matches.length - 1 ? (resolvedCrumb?.link ?? match.pathname) : undefined; diff --git a/web/src/components/engagement/admin/EngagementLoaderAdmin.tsx b/web/src/components/engagement/admin/EngagementLoaderAdmin.tsx index a0460526e..1287fbff7 100644 --- a/web/src/components/engagement/admin/EngagementLoaderAdmin.tsx +++ b/web/src/components/engagement/admin/EngagementLoaderAdmin.tsx @@ -1,4 +1,4 @@ -import { redirect, Params } from 'react-router'; +import { redirect, LoaderFunctionArgs } from 'react-router'; import { getAvailableTranslationLanguages, getEngagement, getEngagementBySlug } from 'services/engagementService'; import { getWidgets } from 'services/widgetService'; import { getEngagementMetadata, getMetadataTaxa } from 'services/engagementMetadataService'; @@ -23,7 +23,7 @@ export type EngagementLoaderAdminData = { hasDefaultLanguageTranslation: Promise; }; -export const engagementLoaderAdmin = async ({ params }: { params: Params }) => { +export const engagementLoaderAdmin = async ({ params }: LoaderFunctionArgs) => { const { slug: slugParam, engagementId } = params; const defaultLanguageCode = AppConfig.language.defaultLanguageId.toLowerCase(); const engagement = (slugParam ? getEngagementBySlug(slugParam) : getEngagement(Number(engagementId))).then( diff --git a/web/src/components/engagement/admin/create/authoring/authoringLoader.tsx b/web/src/components/engagement/admin/create/authoring/authoringLoader.tsx index e540b3ff6..8be41a33c 100644 --- a/web/src/components/engagement/admin/create/authoring/authoringLoader.tsx +++ b/web/src/components/engagement/admin/create/authoring/authoringLoader.tsx @@ -2,7 +2,7 @@ import { Engagement } from 'models/engagement'; import { EngagementDetailsTab } from 'models/engagementDetailsTab'; import { SuggestedEngagement } from 'models/suggestedEngagement'; import { Survey } from 'models/survey'; -import { Params } from 'react-router'; +import { LoaderFunctionArgs } from 'react-router'; import { getDetailsTabs } from 'services/engagementDetailsTabService'; import { getEngagement, getEngagements } from 'services/engagementService'; import { getSurveysPage } from 'services/surveyService'; @@ -23,7 +23,7 @@ export type AuthoringLoaderData = { suggestions: Promise; }; -const authoringLoader = async ({ params }: { params: Params }) => { +const authoringLoader = async ({ params }: LoaderFunctionArgs) => { const { engagementId, tenantId, languageCode } = params; const id = Number(engagementId); const tId = Number(tenantId); diff --git a/web/src/components/engagement/preview/engagementPreviewLoader.tsx b/web/src/components/engagement/preview/engagementPreviewLoader.tsx index 8828f6f6e..ab7b4b011 100644 --- a/web/src/components/engagement/preview/engagementPreviewLoader.tsx +++ b/web/src/components/engagement/preview/engagementPreviewLoader.tsx @@ -1,4 +1,4 @@ -import { Params } from 'react-router'; +import { LoaderFunctionArgs } from 'react-router'; import { getAvailableTranslationLanguages, getEngagement } from 'services/engagementService'; import { getWidgets } from 'services/widgetService'; import { getEngagementMetadata, getMetadataTaxa } from 'services/engagementMetadataService'; @@ -40,7 +40,7 @@ export type EngagementPreviewLoaderData = { * Loads all necessary data for previewing an engagement. * Similar to the public engagement loader but uses engagement ID instead of slug. */ -export const engagementPreviewLoader = async ({ params }: { params: Params }) => { +export const engagementPreviewLoader = async ({ params }: LoaderFunctionArgs) => { const { engagementId, languageCode } = params; if (!engagementId) { diff --git a/web/src/components/engagement/public/email/FailurePanel.tsx b/web/src/components/engagement/public/email/FailurePanel.tsx index 683161b48..cd77495ed 100644 --- a/web/src/components/engagement/public/email/FailurePanel.tsx +++ b/web/src/components/engagement/public/email/FailurePanel.tsx @@ -5,8 +5,11 @@ import { modalStyle } from 'components/common'; import { BodyText, Heading1 } from 'components/common/Typography'; import { When } from 'react-if'; import { Button } from 'components/common/Input/Button'; +import { Link } from 'components/common/Navigation'; const FailurePanel = ({ email, handleClose, tryAgain, isInternal }: FailurePanelProps) => { + /* TODO: Populate this with the tenant configuration from the API */ + const contactEmail = '********@gov.bc.ca'; return ( - We are sorry + We're sorry! - There was a problem with the email address you provided: + There was a problem verifying the email address you provided: - - {email} + + + {email} + @@ -34,15 +39,19 @@ const FailurePanel = ({ email, handleClose, tryAgain, isInternal }: FailurePanel - Please verify your email and try again. + Please ensure you have entered your email address correctly and try again. - {/* TODO: Populate this with the tenant configuration from the API */} - If this problem persists, contact sample@gmail.com + + If this problem persists, please contact{' '} + + {contactEmail} + + - + diff --git a/web/src/components/engagement/public/view/EngagementLoaderPublic.tsx b/web/src/components/engagement/public/view/EngagementLoaderPublic.tsx index 2813fa6ff..b4c8a2a74 100644 --- a/web/src/components/engagement/public/view/EngagementLoaderPublic.tsx +++ b/web/src/components/engagement/public/view/EngagementLoaderPublic.tsx @@ -1,4 +1,4 @@ -import { Params } from 'react-router'; +import { LoaderFunctionArgs } from 'react-router'; import { getAvailableTranslationLanguages, getEngagement, getEngagementBySlug } from 'services/engagementService'; import { getWidgets } from 'services/widgetService'; import { getEngagementMetadata, getMetadataTaxa } from 'services/engagementMetadataService'; @@ -36,7 +36,7 @@ export type AwaitedEngagementLoaderPublicData = { translationBundle: TranslationBundle; }; -export const engagementLoaderPublic = async ({ params }: { params: Params }) => { +export const engagementLoaderPublic = async ({ params }: LoaderFunctionArgs) => { const { slug: slugParam, engagementId, language } = params; const defaultLanguageCode = AppConfig.language.defaultLanguageId.toLowerCase(); const activeLanguageCode = (language ?? defaultLanguageCode).toLowerCase(); diff --git a/web/src/components/survey/building/SurveyLoader.tsx b/web/src/components/survey/building/SurveyLoader.tsx index 97d08cf13..a1fd61813 100644 --- a/web/src/components/survey/building/SurveyLoader.tsx +++ b/web/src/components/survey/building/SurveyLoader.tsx @@ -3,7 +3,7 @@ import { Engagement } from 'models/engagement'; import { Survey } from 'models/survey'; import { SurveyReportSetting } from 'models/surveyReportSetting'; import { SurveySubmission } from 'models/surveySubmission'; -import { Params } from 'react-router'; +import { LoaderFunctionArgs } from 'react-router'; import { getEmailVerification } from 'services/emailVerificationService'; import { getEngagement, getEngagementBySlug } from 'services/engagementService'; import { getSubmissionByToken } from 'services/submissionService'; @@ -11,56 +11,63 @@ import { getSurvey } from 'services/surveyService'; import { fetchSurveyReportSettings } from 'services/surveyService/reportSettingsService'; export type SurveyLoaderData = { - engagement: Engagement | null; - language: string | undefined; - reportSettings: SurveyReportSetting[] | null; - slug: { slug: string } | null; - submission: SurveySubmission | null; - survey: Survey; - surveyId: string | undefined; - token: string | undefined; - verification: EmailVerification | null; + engagement: Promise; + reportSettings: Promise; + submission: Promise; + survey: Promise; + verification: Promise | Promise; }; -export const SurveyLoader = async ({ params }: { params: Params }) => { - const { surveyId, token, language, engagementId: engagementIdParam, slug: slugParam } = params; +export const SurveyLoader = async ({ params, pattern }: LoaderFunctionArgs) => { + const { surveyId: surveyIdParam, token, engagementId: engagementIdParam, slug: slugParam } = params; + const surveyId = Number(surveyIdParam); const engagementId = Number(engagementIdParam); - if (Number.isNaN(Number(surveyId)) && !Number.isNaN(engagementId) && !slugParam) - throw new Error('Invalid survey ID'); - - const verPromise = token ? getEmailVerification(token) : null; - const getEngPromise = () => { - if (slugParam) { - return getEngagementBySlug(slugParam); - } else if (engagementId && !Number.isNaN(engagementId)) { - return getEngagement(engagementId); - } else if (surveyId && !Number.isNaN(Number(surveyId))) { - const surveyPromise = getSurvey(Number(surveyId)); - return surveyPromise.then((surveyData) => { + if (Number.isNaN(surveyId) && !Number.isNaN(engagementId) && !slugParam) throw new Error('Invalid survey ID'); + const shouldHaveToken = !pattern.startsWith('/manage'); //non-admin users should have a token + if (shouldHaveToken && !token) { + throw new Error('Missing verification token'); + } + const verification = token ? getEmailVerification(token) : Promise.resolve(null); + const getPromises = () => { + if (slugParam || (engagementId && !Number.isNaN(engagementId))) { + // If we are accessing the survey via the engagement + const engagement = slugParam ? getEngagementBySlug(slugParam) : getEngagement(engagementId); + const survey = engagement.then((eng) => { + if (!eng) throw new Error('Engagement not found for slug: ' + slugParam); + if (surveyId && !Number.isNaN(surveyId)) { + return getSurvey(surveyId); + } + if (eng.surveys && eng.surveys.length > 0) { + return eng.surveys[0]; + } + throw new Error('No survey found for engagement with slug: ' + slugParam); + }); + return { engagement, survey }; + } else if (surveyId && !Number.isNaN(surveyId)) { + // If we are accessing the survey directly via the survey ID + const survey = getSurvey(surveyId); + const engagement = survey.then((surveyData) => { if (!surveyData.engagement_id) { throw new Error('Survey is missing engagement ID'); } return getEngagement(Number(surveyData.engagement_id)); }); + return { engagement, survey }; } - return null; + throw new Error('Invalid parameters: surveyId or engagementId or slug must be provided'); }; - const engPromise = getEngPromise(); - let survey: Survey | null = null; - try { - const [engagement, verification] = await Promise.all([engPromise, verPromise]); - survey = !survey && surveyId ? await getSurvey(Number(surveyId)) : (survey ?? engagement?.surveys?.[0]) || null; - const submissionPromise = verification?.verification_token - ? getSubmissionByToken(verification.verification_token) - : null; - const reportSettingsPromise = survey?.id ? fetchSurveyReportSettings(String(survey.id)) : null; - const results = await Promise.all([submissionPromise, reportSettingsPromise]); - const [submission, reportSettings] = results; - return { engagement, language, reportSettings, submission, survey, surveyId, token, verification }; - } catch (e) { - console.error('Failed to get survey data', e); - } + const { engagement, survey } = getPromises(); + const reportSettings = survey?.then((s) => { + if (!s) return null; + return fetchSurveyReportSettings(String(s.id)); + }); + const submission = verification?.then((verif) => { + if (!verif?.verification_token) return null; + return getSubmissionByToken(verif.verification_token); + }); + + return { engagement, reportSettings, submission, survey, verification }; }; export default SurveyLoader; diff --git a/web/src/components/survey/building/index.tsx b/web/src/components/survey/building/index.tsx index 9502dc3c5..5672014b6 100644 --- a/web/src/components/survey/building/index.tsx +++ b/web/src/components/survey/building/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState, Suspense } from 'react'; -import { useNavigate, useRouteLoaderData, Await, useRevalidator } from 'react-router'; +import { useNavigate, Await, useRevalidator } from 'react-router'; import FormBuilderSkeleton from './FormBuilderSkeleton'; import { Grid2 as Grid, @@ -41,9 +41,9 @@ import { } from '@fortawesome/pro-regular-svg-icons'; import { Else, If, Then } from 'react-if'; import UnsavedWorkConfirmation from 'components/common/Navigation/UnsavedWorkConfirmation'; -import { SurveyLoaderData } from './SurveyLoader'; import { fetchSurveyReportSettings } from 'services/surveyService/reportSettingsService'; import { ROUTES, getPath } from 'routes/routes'; +import { useSurveyLoaderData } from '../useSurveyLoaderData'; interface SurveyForm { id: string; @@ -58,7 +58,11 @@ export const FormBuilderPage = () => { const dispatch = useAppDispatch(); const revalidator = useRevalidator(); - const { survey, engagement, reportSettings } = useRouteLoaderData('survey'); + const surveyLoaderData = useSurveyLoaderData(); + const survey = React.use(surveyLoaderData.survey); + const engagement = React.use(surveyLoaderData.engagement); + const reportSettings = React.use(surveyLoaderData.reportSettings); + const [settings, setSettings] = useState(reportSettings); const [formDefinition, setFormDefinition] = useState(survey.form_json); const [isMultiPage, setIsMultiPage] = useState(formDefinition?.display === 'wizard'); @@ -74,7 +78,7 @@ export const FormBuilderPage = () => { watch, } = useForm>({ defaultValues: { - id: survey.id.toString(), + id: survey.id?.toString(), name: survey.name, is_hidden: survey.is_hidden, is_template: survey.is_template, @@ -219,13 +223,7 @@ export const FormBuilderPage = () => { name="name" control={control} render={({ field }) => ( - { - setIsEditingName(false); - }} - /> + setIsEditingName(false)} /> )} /> { - const { survey, engagement } = useRouteLoaderData('survey') as SurveyLoaderData; + const { survey, engagement } = useSurveyLoaderData(); return ( }> diff --git a/web/src/components/survey/edit/EditForm.tsx b/web/src/components/survey/edit/EditForm.tsx index 647ba37de..3f6eabad2 100644 --- a/web/src/components/survey/edit/EditForm.tsx +++ b/web/src/components/survey/edit/EditForm.tsx @@ -75,7 +75,7 @@ export const EditForm = ({ handleClose }: SurveyFormProps) => { {submission.comments?.map((comment, index) => { return ( - + {comment.label} { const navigate = useNavigate(); - const [verification, slug, engagement, submission] = useAsyncValue() as [ + const [verification, engagement, submission] = useAsyncValue() as [ EmailVerification | null, - { slug: string }, Engagement, SurveySubmission, ]; const isTokenValid = !!verification; const language = sessionStorage.getItem('languageId') ?? AppConfig.language.defaultLanguageId; const engagementPath = getPath(ROUTES.PUBLIC_ENGAGEMENT_BY_SLUG, { - slug: slug.slug, + slug: engagement.slug, language, }); @@ -42,7 +41,7 @@ const FormWrapped = () => { alignItems="flex-start" m={{ lg: '0 8em 1em 3em', md: '2em', xs: '1em' }} > - + { - - - - + diff --git a/web/src/components/survey/edit/index.tsx b/web/src/components/survey/edit/index.tsx index 873e8d272..630cceeee 100644 --- a/web/src/components/survey/edit/index.tsx +++ b/web/src/components/survey/edit/index.tsx @@ -1,14 +1,14 @@ import React, { Suspense } from 'react'; import SurveySubmitWrapped from './FormWrapped'; -import { Await, useLoaderData } from 'react-router'; +import { Await } from 'react-router'; +import { useSurveyLoaderData } from '../useSurveyLoaderData'; import { Skeleton } from '@mui/material'; -import { SurveyLoaderData } from '../building/SurveyLoader'; const SurveySubmit = () => { - const { verification, slug, engagement, submission } = useLoaderData() as SurveyLoaderData; + const { verification, engagement, submission } = useSurveyLoaderData(); return ( }> - + diff --git a/web/src/components/survey/listing/Surveys.tsx b/web/src/components/survey/listing/Surveys.tsx index 840d89fd3..9f71f53f0 100644 --- a/web/src/components/survey/listing/Surveys.tsx +++ b/web/src/components/survey/listing/Surveys.tsx @@ -454,7 +454,7 @@ const Surveys = () => { }} /> } - fullWidth={isMediumScreen ? true : false} + fullWidth={isMediumScreen} > Advanced Search diff --git a/web/src/components/survey/report/SettingsForm.tsx b/web/src/components/survey/report/SettingsForm.tsx index 3cbfb2f5d..3346332f1 100644 --- a/web/src/components/survey/report/SettingsForm.tsx +++ b/web/src/components/survey/report/SettingsForm.tsx @@ -1,5 +1,5 @@ import React, { Suspense, useState } from 'react'; -import { Await, useNavigate, useRouteLoaderData, useRevalidator } from 'react-router'; +import { Await, useNavigate, useRevalidator, useAsyncValue } from 'react-router'; import { ClickAwayListener, Grid2 as Grid, Stack, InputAdornment, Tooltip, Skeleton, Paper } from '@mui/material'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCopy } from '@fortawesome/pro-regular-svg-icons/faCopy'; @@ -16,8 +16,11 @@ import { BodyText, Heading3 } from 'components/common/Typography'; import { ROUTES, getPath } from 'routes/routes'; import { RouterLinkRenderer } from 'components/common/Navigation/Link'; import { AppConfig } from 'config'; +import { useSurveyLoaderData } from '../useSurveyLoaderData'; +import { Awaited } from 'utils'; const SettingsFormPage = () => { + const { survey, engagement, reportSettings } = useSurveyLoaderData(); return ( { > Report Settings - + }> + Error loading report settings} + > + + + ); }; const SettingsForm = () => { - const { survey, slug, reportSettings } = useRouteLoaderData('survey') as SurveyLoaderData; + const { survey, engagement, reportSettings } = useAsyncValue() as Awaited; const [searchTerm, setSearchTerm] = useState(''); - const engagementSlug = slug?.slug; + const engagementSlug = engagement?.slug; const [displayedSettings, setDisplayedSettings] = useState<{ [key: number]: boolean }>({}); const [copyTooltip, setCopyTooltip] = useState(false); const navigate = useNavigate(); diff --git a/web/src/components/survey/submit/EngagementLink.tsx b/web/src/components/survey/submit/EngagementLink.tsx deleted file mode 100644 index 288d5273f..000000000 --- a/web/src/components/survey/submit/EngagementLink.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { useAsyncValue } from 'react-router'; -import { When } from 'react-if'; -import { Engagement } from 'models/engagement'; -import { Link } from 'components/common/Navigation'; -import UnsavedWorkConfirmation from 'components/common/Navigation/UnsavedWorkConfirmation'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faArrowLeftLong } from '@fortawesome/pro-regular-svg-icons'; -import { ROUTES, getPath } from 'routes/routes'; - -export const EngagementLink = () => { - const engagement = useAsyncValue() as Engagement | null; - - return ( - - - - Back to Surveys - - - ); -}; diff --git a/web/src/components/survey/submit/InvalidTokenModal.tsx b/web/src/components/survey/submit/InvalidTokenModal.tsx index 6ae4779d3..621d1289f 100644 --- a/web/src/components/survey/submit/InvalidTokenModal.tsx +++ b/web/src/components/survey/submit/InvalidTokenModal.tsx @@ -8,18 +8,20 @@ import { BodyText } from 'components/common/Typography/Body'; import { AppConfig } from 'config'; import { getPath, ROUTES } from 'routes/routes'; import { RouterLinkRenderer } from 'components/common/Navigation/Link'; -import { useSurveyLoaderData } from './useSurveyLoaderData'; +import { useSurveyLoaderData } from '../useSurveyLoaderData'; export const InvalidTokenModal = () => { const { t: translate } = useAppTranslation(); const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated); const navigate = useNavigate(); - const { verification, slug } = useSurveyLoaderData(); + const surveyLoaderData = useSurveyLoaderData(); + const engagement = React.use(surveyLoaderData.engagement); + const verification = React.use(surveyLoaderData.verification); const language = sessionStorage.getItem('languageId') ?? AppConfig.language.defaultLanguageId; const engagementPath = - slug === null + engagement === null ? getPath(ROUTES.PUBLIC_LANDING) - : getPath(ROUTES.PUBLIC_ENGAGEMENT_BY_SLUG, { slug: slug.slug, language }); + : getPath(ROUTES.PUBLIC_ENGAGEMENT_BY_SLUG, { slug: engagement.slug, language }); const navigateToEngagement = () => navigate(engagementPath); diff --git a/web/src/components/survey/submit/PreviewBanner.tsx b/web/src/components/survey/submit/PreviewBanner.tsx index 4601730b7..f3b0e5c8a 100644 --- a/web/src/components/survey/submit/PreviewBanner.tsx +++ b/web/src/components/survey/submit/PreviewBanner.tsx @@ -1,19 +1,20 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { Box, Grid2 as Grid, Stack } from '@mui/material'; -import { useAppSelector } from 'hooks'; import { PermissionsGate } from 'components/permissionsGate'; import { USER_ROLES } from 'services/userService/constants'; import { Heading1 } from 'components/common/Typography'; import { Button } from 'components/common/Input'; -import { ROUTES, getPath } from 'routes/routes'; +import { ROUTES, getPath, useIsManagementRoute } from 'routes/routes'; import { RouterLinkRenderer } from 'components/common/Navigation/Link'; -import { useSurveyLoaderData } from './useSurveyLoaderData'; +import { useSurveyLoaderData } from '../useSurveyLoaderData'; +import { Await } from 'react-router'; export const PreviewBanner = () => { - const { survey } = useSurveyLoaderData(); - const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated); + const surveyLoaderData = useSurveyLoaderData(); + const isManagementPath = useIsManagementRoute(); - if (!isLoggedIn || !survey) { + if (!isManagementPath) { + // Only show the "preview" banner if we are an admin user previewing the survey on the management side. return null; } @@ -26,12 +27,24 @@ export const PreviewBanner = () => { - + } > - Edit Survey - + + {(survey) => ( + + )} + + diff --git a/web/src/components/survey/submit/SurveyBanner.tsx b/web/src/components/survey/submit/SurveyBanner.tsx index 75c209713..d4314cc09 100644 --- a/web/src/components/survey/submit/SurveyBanner.tsx +++ b/web/src/components/survey/submit/SurveyBanner.tsx @@ -1,16 +1,25 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { Banner } from 'components/banner/Banner'; import EngagementInfoSection from 'components/publicDashboard/EngagementInfoSection'; -import { useSurveyLoaderData } from './useSurveyLoaderData'; +import { useSurveyLoaderData } from '../useSurveyLoaderData'; +import { Await } from 'react-router'; +import Skeleton from '@mui/material/Skeleton/Skeleton'; export const SurveyBanner = () => { - const { engagement } = useSurveyLoaderData(); - - if (!engagement) return null; + const loaderData = useSurveyLoaderData(); return ( - - - + }> + Error loading engagement data}> + {(engagement) => { + if (!engagement) return null; + return ( + + + + ); + }} + + ); }; diff --git a/web/src/components/survey/submit/SurveyForm.tsx b/web/src/components/survey/submit/SurveyForm.tsx index eb0d127b7..2dc683a94 100644 --- a/web/src/components/survey/submit/SurveyForm.tsx +++ b/web/src/components/survey/submit/SurveyForm.tsx @@ -1,56 +1,62 @@ -import React, { useRef, useState } from 'react'; -import { Grid2 as Grid, Stack } from '@mui/material'; +import React, { Suspense, useRef, useState } from 'react'; +import { Grid2 as Grid, Skeleton, Stack } from '@mui/material'; import FormSubmit from 'components/Form/FormSubmit'; import { FormSubmissionData } from 'components/Form/types'; -import { useAppDispatch, useAppSelector, useAppTranslation } from 'hooks'; +import { useAppDispatch, useAppTranslation } from 'hooks'; import { When } from 'react-if'; import { submitSurvey } from 'services/submissionService'; -import { useNavigate } from 'react-router'; +import { Await, useNavigate, useParams } from 'react-router'; import { Button } from 'components/common/Input/Button'; import { openNotification } from 'services/notificationService/notificationSlice'; import UnsavedWorkConfirmation from 'components/common/Navigation/UnsavedWorkConfirmation'; -import { getPath, ROUTES } from 'routes/routes'; +import { getPath, ROUTES, useIsManagementRoute } from 'routes/routes'; import { AppConfig } from 'config'; -import { useSurveyLoaderData } from './useSurveyLoaderData'; +import { useSurveyLoaderData } from '../useSurveyLoaderData'; export const SurveyForm = () => { const { t: translate } = useAppTranslation(); const navigate = useNavigate(); const dispatch = useAppDispatch(); - const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated); + const params = useParams<{ token: string }>(); const language = sessionStorage.getItem('languageId') ?? AppConfig.language.defaultLanguageId; const [submissionData, setSubmissionData] = useState(null); const initialSet = useRef(false); // Track if the initial state has been set const [isValid, setIsValid] = useState(false); const [isChanged, setIsChanged] = useState(false); + const isManagementRoute = useIsManagementRoute(); + const loaderData = useSurveyLoaderData(); - const { survey, verification, slug } = useSurveyLoaderData(); - - const token = verification?.verification_token; + const token = params.token; const handleChange = (filledForm: FormSubmissionData) => { - if (!initialSet.current) { - initialSet.current = true; - } else { + if (initialSet.current) { setIsChanged(true); + } else { + initialSet.current = true; } setSubmissionData(filledForm.data); setIsValid(filledForm.isValid); }; - const navigateToEngagement = () => { - if (slug) { + const navigateToEngagement = (open: boolean = true) => { + const engagement = React['use'](loaderData.engagement); + if (engagement) { navigate( getPath(ROUTES.PUBLIC_ENGAGEMENT_BY_SLUG, { - slug: slug.slug, + slug: engagement.slug, language, }), + { state: { open } }, ); } }; const handleSubmit = async (submissionData: unknown) => { + const survey = React['use'](loaderData.survey); + if (isManagementRoute) { + return; + } try { await submitSurvey({ survey_id: survey.id, @@ -72,15 +78,7 @@ export const SurveyForm = () => { text: translate('surveySubmit.surveySubmitNotification.success'), }), ); - if (slug) { - navigate( - getPath(ROUTES.PUBLIC_ENGAGEMENT_BY_SLUG, { - slug: slug.slug, - language, - }), - { state: { open: true } }, - ); - } + navigateToEngagement(); } catch { dispatch( openNotification({ @@ -100,35 +98,71 @@ export const SurveyForm = () => { spacing={1} padding={'2em 2em 1em 2em'} > - - - - - - - - - - - - + + }> + + {(survey) => ( + <> + + navigateToEngagement(false)} + verificationToken={token} + /> + + + + + + + {translate('surveySubmit.surveyForm.button.cancel')} + + } + > + + {(engagement) => ( + + )} + + + + + + + + )} + + ); }; diff --git a/web/src/components/survey/submit/index.tsx b/web/src/components/survey/submit/index.tsx index ec6105932..89cd90b3c 100644 --- a/web/src/components/survey/submit/index.tsx +++ b/web/src/components/survey/submit/index.tsx @@ -1,10 +1,13 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { Grid2 as Grid, Paper } from '@mui/material'; import { SurveyBanner } from './SurveyBanner'; import { SurveyForm } from './SurveyForm'; import { InvalidTokenModal } from './InvalidTokenModal'; -import { EngagementLink } from './EngagementLink'; import { PreviewBanner } from './PreviewBanner'; +import { Link } from 'components/common/Navigation'; +import { getPath, ROUTES, useIsManagementRoute } from 'routes/routes'; +import { faArrowLeftLong } from '@fortawesome/pro-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; const SurveySubmit = () => { return ( @@ -24,12 +27,20 @@ const SurveySubmit = () => { m={{ lg: '2em 8em 1em 3em', md: '2em', xs: '1em' }} > - + {useIsManagementRoute() && ( + + Back to Surveys + + )} - - + + + + + + diff --git a/web/src/components/survey/submit/useSurveyLoaderData.ts b/web/src/components/survey/submit/useSurveyLoaderData.ts deleted file mode 100644 index dd5374c07..000000000 --- a/web/src/components/survey/submit/useSurveyLoaderData.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useRouteLoaderData } from 'react-router'; -import { SurveyLoaderData } from '../building/SurveyLoader'; - -export const useSurveyLoaderData = () => { - const surveyLoaderData = useRouteLoaderData('survey'); - const publicSurveyLoaderData = useRouteLoaderData('public-survey'); - - return (surveyLoaderData ?? publicSurveyLoaderData) as SurveyLoaderData; -}; diff --git a/web/src/components/survey/useSurveyLoaderData.ts b/web/src/components/survey/useSurveyLoaderData.ts new file mode 100644 index 000000000..8c3af02b8 --- /dev/null +++ b/web/src/components/survey/useSurveyLoaderData.ts @@ -0,0 +1,12 @@ +import { useRouteLoaderData, useLoaderData } from 'react-router'; +import { SurveyLoaderData } from './building/SurveyLoader'; + +export const useSurveyLoaderData = () => { + const surveyLoaderData = useRouteLoaderData('survey'); + const publicSurveyLoaderData = useRouteLoaderData('public-survey'); + // if the route is not a child of one of the 'survey' routes, + // we can still expect the survey data from the nearest route's loader + const fallbackData = useLoaderData(); + + return (surveyLoaderData ?? publicSurveyLoaderData ?? fallbackData) as SurveyLoaderData; +}; diff --git a/web/src/components/tenantManagement/tenantLoader.ts b/web/src/components/tenantManagement/tenantLoader.ts index 27b1ddd06..a30e93362 100644 --- a/web/src/components/tenantManagement/tenantLoader.ts +++ b/web/src/components/tenantManagement/tenantLoader.ts @@ -1,4 +1,4 @@ -import { Params } from 'react-router'; +import { LoaderFunctionArgs } from 'react-router'; import { getAllTenants, getTenant } from 'services/tenantService'; export const allTenantsLoader = () => { @@ -6,7 +6,7 @@ export const allTenantsLoader = () => { return tenants; }; -export const tenantLoader = ({ params }: { params: Params }) => { +export const tenantLoader = ({ params }: LoaderFunctionArgs) => { const { tenantShortName } = params; if (!tenantShortName) throw new Error('Tenant ID is required'); diff --git a/web/src/components/userManagement/userDetails/userDetailsLoader.tsx b/web/src/components/userManagement/userDetails/userDetailsLoader.tsx index e6431482b..721712b98 100644 --- a/web/src/components/userManagement/userDetails/userDetailsLoader.tsx +++ b/web/src/components/userManagement/userDetails/userDetailsLoader.tsx @@ -1,4 +1,4 @@ -import { Params } from 'react-router'; +import { LoaderFunctionArgs } from 'react-router'; import { getUser } from 'services/userService/api'; import { User } from 'models/user'; @@ -6,7 +6,7 @@ export interface UserDetailsLoaderData { user: Promise; } -export const userDetailsLoader = ({ params }: { params: Params }): UserDetailsLoaderData => { +export const userDetailsLoader = ({ params }: LoaderFunctionArgs): UserDetailsLoaderData => { const { userId } = params; if (!userId || Number.isNaN(Number(userId))) { diff --git a/web/src/routes/UnauthenticatedRoutes.tsx b/web/src/routes/UnauthenticatedRoutes.tsx index 15eab7876..8dca1ab93 100644 --- a/web/src/routes/UnauthenticatedRoutes.tsx +++ b/web/src/routes/UnauthenticatedRoutes.tsx @@ -43,7 +43,7 @@ const UnauthenticatedRoutes = resolveLazyRouteTree( handleLazy={() => import('routes/UnauthenticatedRouteHandles').then((m) => m.publicCommentsHandle)} /> import('components/survey/edit').then((module) => withLanguageParam(module.default)) } diff --git a/web/src/routes/routes.ts b/web/src/routes/routes.ts index 79123b5b4..bb5a19baa 100644 --- a/web/src/routes/routes.ts +++ b/web/src/routes/routes.ts @@ -1,4 +1,10 @@ -import { generatePath } from 'react-router'; +import { generatePath, useMatches } from 'react-router'; + +export const ROOT_MANAGEMENT_ROUTE_ID = 'authenticated-root'; + +export const useIsManagementRoute = () => { + return useMatches().some((match) => match.id === ROOT_MANAGEMENT_ROUTE_ID); +}; export const ROUTES = { // Public routes diff --git a/web/src/services/emailVerificationService/index.ts b/web/src/services/emailVerificationService/index.ts index 44faaf2fb..1c59ff8e5 100644 --- a/web/src/services/emailVerificationService/index.ts +++ b/web/src/services/emailVerificationService/index.ts @@ -5,27 +5,23 @@ import { EmailVerification, EmailVerificationType } from 'models/emailVerificati export const getEmailVerification = async (token: string): Promise => { if (!token) { - return Promise.reject('Invalid Token'); + throw new Error('Invalid Token'); } const url = replaceUrl(Endpoints.EmailVerification.GET, 'verification_token', token); const response = await http.GetRequest(url); if (response.data) { return response.data; } - return Promise.reject('Failed to fetch email verification'); + throw new Error('Failed to fetch email verification'); }; export const verifyEmailVerification = async (token: string): Promise => { if (!token) { - return Promise.reject('Invalid Token'); + throw new Error('Invalid Token'); } const url = replaceUrl(Endpoints.EmailVerification.UPDATE, 'verification_token', token); - try { - const response = await http.PutRequest(url); - return response.data; - } catch (err) { - return Promise.reject(err); - } + const response = await http.PutRequest(url); + return response.data; }; interface CreateEmailVerification { @@ -35,27 +31,19 @@ interface CreateEmailVerification { language: string; } export const createEmailVerification = async (request: CreateEmailVerification): Promise => { - try { - const response = await http.PostRequest(Endpoints.EmailVerification.CREATE, request); - return response.data; - } catch (err) { - return Promise.reject(err); - } + const response = await http.PostRequest(Endpoints.EmailVerification.CREATE, request); + return response.data; }; export const createSubscribeEmailVerification = async ( request: CreateEmailVerification, subscription_type: string, ): Promise => { - try { - const url = replaceUrl( - Endpoints.EmailVerification.CREATE_SUBSCRIBE, - 'subscription_type', - String(subscription_type), - ); - const response = await http.PostRequest(url, request); - return response.data; - } catch (err) { - return Promise.reject(err); - } + const url = replaceUrl( + Endpoints.EmailVerification.CREATE_SUBSCRIBE, + 'subscription_type', + String(subscription_type), + ); + const response = await http.PostRequest(url, request); + return response.data; }; diff --git a/web/src/services/objectStorageService/index.ts b/web/src/services/objectStorageService/index.ts index aa70216ee..c3bc8dec0 100644 --- a/web/src/services/objectStorageService/index.ts +++ b/web/src/services/objectStorageService/index.ts @@ -1,6 +1,8 @@ +import axios, { AxiosRequestConfig } from 'axios'; import http from 'apiManager/httpRequestHandler'; import API from 'apiManager/endpoints'; -import { ObjectStorageFileDetails, ObjectStorageHeaderDetails } from './types'; +import { ObjectStorageFileDetails, ObjectStorageHeaderDetails, PublicObjectStorageUploadRequest } from './types'; +import { downloadFile } from 'utils'; const getOSSHeaderDetails = async (data: ObjectStorageFileDetails) => { return await http.PostRequest(API.Document.OSS_HEADER, [data]); @@ -16,7 +18,7 @@ const getObject = async (headerDetails: ObjectStorageHeaderDetails) => { export const downloadObject = async (file: ObjectStorageFileDetails) => { const response = await getOSSHeaderDetails(file); if (!response.data) { - throw Error('Error occurred while fetching document from the object storage'); + throw new Error('Error occurred while fetching a document from object storage'); } return await getObject(response.data[0]); }; @@ -31,8 +33,88 @@ const doSaveObjectRequest = async (headerDetails: ObjectStorageHeaderDetails, fi export const saveObject = async (file: File, fileDetails: ObjectStorageFileDetails) => { const fileDetailsResponse = await getOSSHeaderDetails(fileDetails); if (!fileDetailsResponse.data) { - throw Error('Error occurred while fetching document from the object storage'); + throw new Error('Error occurred while fetching a document from object storage'); } await doSaveObjectRequest(fileDetailsResponse.data[0], file); return fileDetailsResponse.data[0]; }; + +const getPublicUploadDetails = async (data: PublicObjectStorageUploadRequest) => { + return await axios.post(API.Document.PUBLIC, data); +}; + +const uploadPublicObject = async ( + headerDetails: ObjectStorageHeaderDetails, + file: File, + config?: AxiosRequestConfig, +) => { + return await axios.put(headerDetails.filepath, file, { + ...config, + headers: { + ...config?.headers, + 'Content-Type': headerDetails.content_type ?? file.type, + 'X-Amz-Date': headerDetails.amzdate, + Authorization: headerDetails.authheader, + }, + }); +}; + +export const savePublicObject = async (file: File, verificationToken: string, config?: AxiosRequestConfig) => { + const fileDetailsResponse = await getPublicUploadDetails({ + filename: file.name, + content_type: file.type || 'application/octet-stream', + size: file.size, + verification_token: verificationToken, + }); + if (!fileDetailsResponse.data) { + throw new Error('Error occurred while fetching document upload details from object storage'); + } + + await uploadPublicObject(fileDetailsResponse.data, file, config); + return fileDetailsResponse.data; +}; + +const getPublicDownloadDetails = async (fileId: string, verificationToken: string) => { + return await axios.get(API.Document.PUBLIC, { + params: { + file_id: fileId, + }, + headers: { + 'Verification-Token': verificationToken, + }, + }); +}; + +export const downloadPublicObject = async (fileId: string, verificationToken: string) => { + const response = await getPublicDownloadDetails(fileId, verificationToken); + if (!response.data) { + throw new Error('Error occurred while fetching document download details from object storage'); + } + + const blobResponse = await axios.get(response.data.filepath, { + headers: { + 'X-Amz-Date': response.data.amzdate, + Authorization: response.data.authheader, + }, + responseType: 'blob', + }); + + const fallbackFileName = fileId.split('/').pop() || 'download'; + downloadFile(blobResponse, fallbackFileName); +}; + +export const deletePublicObject = async (fileId: string, verificationToken: string) => { + const response = await axios.delete(API.Document.PUBLIC, { + params: { + file_id: fileId, + }, + headers: { + 'Verification-Token': verificationToken, + }, + }); + if (response.status !== 204) { + throw new Error( + `Error occurred while deleting document from object storage: ${response.status} – ${response.statusText}`, + ); + } +}; diff --git a/web/src/services/objectStorageService/types.ts b/web/src/services/objectStorageService/types.ts index 9e01ebfdc..7856d743e 100644 --- a/web/src/services/objectStorageService/types.ts +++ b/web/src/services/objectStorageService/types.ts @@ -4,8 +4,17 @@ export interface ObjectStorageHeaderDetails { authheader: string; amzdate: string; uniquefilename: string; + content_type?: string; + size?: number; } export interface ObjectStorageFileDetails { filename: string; } + +export interface PublicObjectStorageUploadRequest { + filename: string; + content_type: string; + size: number; + verification_token: string; +} diff --git a/web/tests/unit/components/survey/SurveyReportSettings.test.tsx b/web/tests/unit/components/survey/SurveyReportSettings.test.tsx index 3b7cdf789..8f5da232d 100644 --- a/web/tests/unit/components/survey/SurveyReportSettings.test.tsx +++ b/web/tests/unit/components/survey/SurveyReportSettings.test.tsx @@ -5,10 +5,25 @@ import { setupEnv } from '../setEnvVars'; import * as reportSettingsService from 'services/surveyService/reportSettingsService'; import { createDefaultSurvey, Survey } from 'models/survey'; import ReportSettings from 'components/survey/report'; -import { MemoryRouter } from 'react-router'; +import { createMemoryRouter, RouterProvider } from 'react-router'; import { SurveyReportSetting } from 'models/surveyReportSetting'; import { createDefaultEngagement } from 'models/engagement'; +global['Request'] = jest.fn().mockImplementation((input: string = '', init: RequestInit = {}) => ({ + // React Router data APIs call toUpperCase on request.method; default to GET + method: (init.method || 'GET').toUpperCase(), + url: input, + headers: { + get: jest.fn(), + has: jest.fn(), + }, + signal: { + removeEventListener: jest.fn(), + addEventListener: jest.fn(), + }, + clone: jest.fn(), +})); + const survey: Survey = { ...createDefaultSurvey(), engagement: { ...createDefaultEngagement() }, @@ -164,12 +179,22 @@ describe('Survey report settings tests', () => { __TESTING__.submitImplRef.impl = undefined; }); - function renderWithRouter(ui: React.ReactElement) { - return render({ui}); + function renderPage() { + const router = createMemoryRouter( + [ + { + element: , + path: '/surveys/1/report', + }, + ], + { initialEntries: ['/surveys/1/report'] }, + ); + + return render(); } test('View survey report settings page', async () => { - renderWithRouter(); + renderPage(); await waitFor(() => { expect(screen.getByText(surveyReportSettingOne.question_type)).toBeVisible(); @@ -183,7 +208,7 @@ describe('Survey report settings tests', () => { }); test('Search question by question text', async () => { - const { container } = renderWithRouter(); + const { container } = renderPage(); await waitFor(() => { expect(screen.getByText(surveyReportSettingOne.question)).toBeVisible(); @@ -207,7 +232,7 @@ describe('Survey report settings tests', () => { }); test('Survey report settings can be updated', async () => { - renderWithRouter(); + renderPage(); await waitFor(() => { expect(screen.getByText(surveyReportSettingOne.question)).toBeVisible();