diff --git a/submit-api/migrations/versions/d4e5f6a7b8c9_add_is_approved_to_geo_uploads.py b/submit-api/migrations/versions/d4e5f6a7b8c9_add_is_approved_to_geo_uploads.py new file mode 100644 index 000000000..31f96b23f --- /dev/null +++ b/submit-api/migrations/versions/d4e5f6a7b8c9_add_is_approved_to_geo_uploads.py @@ -0,0 +1,30 @@ +"""Add is_approved column to geo_data_uploads + +Revision ID: d4e5f6a7b8c9 +Revises: 8bc9fafd24f1 +Create Date: 2026-07-07 00:00:00.000000 + +Adds a boolean flag recording whether the proponent explicitly approved the +geospatial file in the preview modal. Existing rows default to False so that +previously uploaded (un-reviewed) files must be re-reviewed before the item +can be completed. +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'd4e5f6a7b8c9' +down_revision = '8bc9fafd24f1' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'geo_data_uploads', + sa.Column('is_approved', sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade(): + op.drop_column('geo_data_uploads', 'is_approved') diff --git a/submit-api/src/submit_api/models/geo_data_upload.py b/submit-api/src/submit_api/models/geo_data_upload.py index 07f3958e8..b87a37964 100644 --- a/submit-api/src/submit_api/models/geo_data_upload.py +++ b/submit-api/src/submit_api/models/geo_data_upload.py @@ -19,7 +19,7 @@ from datetime import datetime, UTC -from sqlalchemy import Column, DateTime, Float, Integer, JSON, String, Text +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text from .base_model import BaseModel @@ -37,6 +37,7 @@ class GeoDataUpload(BaseModel): # pylint: disable=too-many-instance-attributes preview_s3_key = Column(String(512), nullable=True) standard_s3_key = Column(String(512), nullable=True) status = Column(String(20), nullable=False, default='processing') + is_approved = Column(Boolean, nullable=False, default=False) error_message = Column(Text, nullable=True) feature_count = Column(Integer, nullable=True) geometry_type = Column(String(50), nullable=True) @@ -62,6 +63,7 @@ def __init__( # pylint: disable=too-many-arguments,R0917 preview_s3_key: str | None = None, standard_s3_key: str | None = None, status: str = 'processing', + is_approved: bool = False, error_message: str | None = None, feature_count: int | None = None, geometry_type: str | None = None, @@ -78,6 +80,7 @@ def __init__( # pylint: disable=too-many-arguments,R0917 self.preview_s3_key = preview_s3_key self.standard_s3_key = standard_s3_key self.status = status + self.is_approved = is_approved self.error_message = error_message self.feature_count = feature_count self.geometry_type = geometry_type @@ -96,6 +99,7 @@ def to_dict(self): 'preview_s3_key': self.preview_s3_key, 'standard_s3_key': self.standard_s3_key, 'status': self.status, + 'is_approved': self.is_approved, 'error_message': self.error_message, 'feature_count': self.feature_count, 'geometry_type': self.geometry_type, diff --git a/submit-api/src/submit_api/resources/geo.py b/submit-api/src/submit_api/resources/geo.py index ea54e85c2..a2a72e1b2 100644 --- a/submit-api/src/submit_api/resources/geo.py +++ b/submit-api/src/submit_api/resources/geo.py @@ -144,6 +144,26 @@ def get(upload_id): return result, HTTPStatus.OK +@cors_preflight("POST, OPTIONS") +@API.route("//approve", methods=["POST", "OPTIONS"]) +class GeoUploadApprove(Resource): + """Resource for approving a processed upload.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @auth.require + def post(upload_id): + """Mark a ready upload as approved by the proponent.""" + try: + upload = GeoService.approve_upload(upload_id) + except LookupError as exc: + return {"error": str(exc)}, HTTPStatus.NOT_FOUND + except ValueError as exc: + return {"error": str(exc)}, HTTPStatus.BAD_REQUEST + + return {"id": upload.id, "status": upload.status, "is_approved": upload.is_approved}, HTTPStatus.OK + + @cors_preflight("POST, OPTIONS") @API.route("//retry", methods=["POST", "OPTIONS"]) class GeoUploadRetry(Resource): diff --git a/submit-api/src/submit_api/services/geo/processor.py b/submit-api/src/submit_api/services/geo/processor.py index 3a9ba240f..8ded5880d 100644 --- a/submit-api/src/submit_api/services/geo/processor.py +++ b/submit-api/src/submit_api/services/geo/processor.py @@ -23,12 +23,14 @@ import json import logging import math +import multiprocessing import os import tempfile import threading import zipfile import glob from datetime import datetime, timedelta, timezone +from queue import Empty from typing import Any, Dict import pandas as pd import geopandas as gpd @@ -273,14 +275,77 @@ def process_geo_file(local_path: str) -> Dict[str, Any]: GEO_MAX_CONCURRENT_JOBS = int(os.environ.get("GEO_MAX_CONCURRENT_JOBS", 3)) _GEO_SEMAPHORE = threading.Semaphore(GEO_MAX_CONCURRENT_JOBS) +# Hard wall-clock limit for converting a single file. A malformed or overly large +# file can make geopandas spin indefinitely, leaving the upload stuck in +# 'processing' forever. When exceeded, the worker process is forcibly killed and +# the upload is marked as failed. Configurable via GEO_PROCESSING_TIMEOUT_SECONDS. +GEO_PROCESSING_TIMEOUT_SECONDS = int(os.environ.get("GEO_PROCESSING_TIMEOUT_SECONDS", 60)) + + +def _process_geo_file_worker(local_path: str, result_queue: "multiprocessing.Queue") -> None: + """Child-process entry point: run process_geo_file and push the outcome onto the queue. + + Runs in a separate process so a runaway conversion can be terminated by the + parent. The result dict (GeoJSON bytes + metadata) is picklable; exceptions + are stringified because arbitrary exception types may not be. + """ + try: + result_queue.put(("ok", process_geo_file(local_path))) + except Exception as exc: # pylint: disable=broad-except # noqa: B902 + result_queue.put(("error", str(exc))) + + +def process_geo_file_with_timeout( + local_path: str, timeout_seconds: int = GEO_PROCESSING_TIMEOUT_SECONDS +) -> Dict[str, Any]: + """Run process_geo_file in a child process, enforcing a hard wall-clock timeout. + + Raises: + TimeoutError: if processing exceeds ``timeout_seconds`` (the child is killed). + RuntimeError: if the child process fails or dies without producing a result. + """ + ctx = multiprocessing.get_context() + result_queue: "multiprocessing.Queue" = ctx.Queue() + proc = ctx.Process( + target=_process_geo_file_worker, + args=(local_path, result_queue), + daemon=True, + ) + proc.start() + + try: + # Block until the child publishes a result or the timeout elapses. Reading + # from the queue before join() avoids a deadlock when the result is large. + status, payload = result_queue.get(timeout=timeout_seconds) + except Empty as exc: + logger.warning( + "Geospatial processing exceeded %ss for %s; terminating worker process.", + timeout_seconds, local_path, + ) + proc.terminate() + proc.join(timeout=5) + if proc.is_alive(): + proc.kill() + raise TimeoutError( + f"Processing timed out after {timeout_seconds} seconds and was stopped." + ) from exc + finally: + result_queue.close() + + proc.join(timeout=5) + + if status == "error": + raise RuntimeError(payload) + return payload + class GeoService: """Service class for geospatial upload management.""" # -- Background worker --------------------------------------------------- - @staticmethod - def _validate_or_fail(upload: GeoDataUpload, local_path: str) -> bool: + @classmethod + def _validate_or_fail(cls, upload: GeoDataUpload, local_path: str) -> bool: """Run attribute validation, persisting a failure state if invalid. Returns True if the upload passed validation (or validation is skipped), @@ -301,21 +366,51 @@ def _validate_or_fail(upload: GeoDataUpload, local_path: str) -> bool: ) upload.status = "validation_failed" upload.validation_errors = errors + upload.error_message = cls._build_validation_message(errors, summary) + db.session.commit() + return False + + @staticmethod + def _build_validation_message(errors: list[dict], summary: Dict[str, Any]) -> str: + """Compose a human-readable summary covering each distinct failure type. + + The structured ``validation_errors`` list carries the per-feature detail; + this string is the at-a-glance headline shown to the proponent. + """ + def _count(*types: str) -> int: + return sum(1 for e in errors if e.get("error_type") in types) + missing_cols = [ e["dbf_column"] for e in errors if e.get("error_type") == "missing_column" ] + null_geom = _count("null_geometry") + self_int = _count("self_intersection") + invalid_geom = _count("invalid_geometry") + attr_issues = _count("missing_value", "invalid_code") + + parts: list[str] = [] if missing_cols: - upload.error_message = ( - "The shapefile is missing required column(s): " - f"{', '.join(missing_cols)}." + parts.append(f"missing required column(s): {', '.join(missing_cols)}") + if null_geom: + parts.append(f"{null_geom} feature(s) with missing or empty geometry") + if self_int: + parts.append(f"{self_int} self-intersecting polygon(s)") + if invalid_geom: + parts.append(f"{invalid_geom} feature(s) with invalid geometry") + if attr_issues: + parts.append( + f"{attr_issues} attribute value issue(s) in: " + f"{', '.join(summary['fields_with_errors']) or 'N/A'}" ) - else: - upload.error_message = ( - f"Attribute validation failed with {summary['total_errors']} issue(s) in: " - f"{', '.join(summary['fields_with_errors']) or 'N/A'}." + + if not parts: + parts.append( + f"{summary['total_errors']} issue(s) in: " + f"{', '.join(summary['fields_with_errors']) or 'N/A'}" ) - db.session.commit() - return False + + prefix = "Capped at first " + str(len(errors)) + " issue(s). " if summary.get("capped") else "" + return prefix + "Validation failed: " + "; ".join(parts) + "." @staticmethod def _upload_processed_tiers(upload: GeoDataUpload, result: Dict[str, Any]) -> None: @@ -377,7 +472,7 @@ def _process_upload_in_background(cls, app, upload_id: int) -> None: if not cls._validate_or_fail(upload, local_path): return - result = process_geo_file(local_path) + result = process_geo_file_with_timeout(local_path) cls._upload_processed_tiers(upload, result) metadata = result["metadata"] @@ -514,6 +609,46 @@ def get_presigned_read_url(cls, upload_id: int, tier: str = "preview") -> dict: "crs_original": upload.crs_original, } + @classmethod + def approve_upload(cls, upload_id: int) -> GeoDataUpload: + """Mark a processed upload as approved by the proponent. + + Raises: + LookupError: if the upload does not exist. + ValueError: if the upload is not in a 'ready' state. + """ + upload = cls.get_upload(upload_id) + if not upload: + raise LookupError(f"GeoDataUpload {upload_id} not found.") + if upload.status != "ready": + raise ValueError( + f"Only a ready upload can be approved (status: {upload.status})." + ) + + upload.is_approved = True + db.session.commit() + return upload + + @classmethod + def has_unapproved_uploads(cls, item_id: int) -> bool: + """Return True if the item has any geospatial upload not yet approved. + + Covers uploads still processing, failed, or ready-but-unapproved. Used to + block completing a submission item until every file has been reviewed. + """ + uploads = cls.list_uploads(item_id=item_id) + return any(not upload.is_approved for upload in uploads) + + @classmethod + def has_unapproved_uploads_for_package(cls, package_id: int) -> bool: + """Return True if any geospatial upload in the package is not yet approved. + + Used to block submitting a package to the EAO until every geospatial file + across all of its items has been reviewed and approved. + """ + uploads = cls.list_uploads(package_id=package_id) + return any(not upload.is_approved for upload in uploads) + @classmethod def retry_upload(cls, app, upload_id: int) -> GeoDataUpload: """Reset a failed upload and re-trigger processing. @@ -529,6 +664,7 @@ def retry_upload(cls, app, upload_id: int) -> GeoDataUpload: raise ValueError("Only failed uploads can be retried.") upload.status = "processing" + upload.is_approved = False upload.error_message = None upload.validation_errors = None db.session.commit() diff --git a/submit-api/src/submit_api/services/geo/validation_rules.py b/submit-api/src/submit_api/services/geo/validation_rules.py index c66daa84d..a6e3ecef2 100644 --- a/submit-api/src/submit_api/services/geo/validation_rules.py +++ b/submit-api/src/submit_api/services/geo/validation_rules.py @@ -42,6 +42,13 @@ # back on without rework once the spec is confirmed. VALIDATE_VALUES: bool = False +# Master switch for per-feature geometry validation (null/empty geometry and +# invalid topology such as self-intersecting polygons). Independent of +# VALIDATE_VALUES because geometry integrity does not depend on the attribute +# allowed-value spec. Set False to fall back to the old behaviour of silently +# dropping bad geometries during processing. +VALIDATE_GEOMETRY: bool = True + ALLOWED_VALUES: dict[str, set[str]] = { "category": {"PN", "EAC", "PD"}, "sub_category": {"Pre EA", "EE", "RD", "PP", "ADR", "EAR", "P", "C", "O", "CM", "D", "A", "N"}, @@ -52,7 +59,12 @@ } # Fields whose value may be null/empty — all others are required. -NULLABLE_FIELDS: set[str] = {"sensitive_data"} +NULLABLE_FIELDS: set[str] = {"sensitive_data", "footprint"} + +# Fields that are NOT required to be present as a column in the shapefile. +# A missing column for one of these fields does not fail validation; footprint +# is optional and files without it must still pass. +OPTIONAL_FIELDS: set[str] = {"footprint"} # Set to True to fold both the feature value and the allowed set to uppercase # before comparing. False = exact case match. diff --git a/submit-api/src/submit_api/services/geo/validator.py b/submit-api/src/submit_api/services/geo/validator.py index 037f4d405..6ecc836ae 100644 --- a/submit-api/src/submit_api/services/geo/validator.py +++ b/submit-api/src/submit_api/services/geo/validator.py @@ -20,7 +20,8 @@ Both return (is_valid: bool, errors: list[dict], summary: dict). Error dict keys: feature_index, field, dbf_column, error_type, value, message. -Error types: missing_column | missing_value | invalid_code | no_shapefile. +Error types: missing_column | missing_value | invalid_code | no_shapefile | + null_geometry | self_intersection | invalid_geometry. Fiona is used for streaming so the entire feature set is never held in memory. """ @@ -33,6 +34,8 @@ import zipfile import fiona +from shapely.geometry import shape +from shapely.validation import explain_validity from submit_api.services.geo.validation_rules import ( ALLOWED_VALUES, @@ -40,6 +43,8 @@ FIELD_MAP, MAX_ERRORS, NULLABLE_FIELDS, + OPTIONAL_FIELDS, + VALIDATE_GEOMETRY, VALIDATE_VALUES, ) @@ -97,6 +102,67 @@ def _validate_feature(idx: int, props: dict) -> _ErrorList: return feature_errors +def _validate_geometry(idx: int, geom: dict | None) -> _ErrorList: + """Validate a single feature's geometry. + + Detects three distinct failures and tags each with its own ``error_type``: + - ``null_geometry`` — geometry is absent or empty. + - ``self_intersection`` — invalid topology caused by a self-intersection. + - ``invalid_geometry`` — any other invalid topology (ring order, etc.). + + Returns a list of error dicts (empty if the geometry is valid). + """ + if geom is None: + return [{ + "feature_index": idx, + "field": "geometry", + "dbf_column": None, + "error_type": "null_geometry", + "value": None, + "message": f"Feature {idx}: geometry is missing (null).", + }] + + try: + shp = shape(geom) + except (TypeError, ValueError, AttributeError) as exc: + return [{ + "feature_index": idx, + "field": "geometry", + "dbf_column": None, + "error_type": "invalid_geometry", + "value": None, + "message": f"Feature {idx}: geometry could not be parsed ({exc}).", + }] + + if shp.is_empty: + return [{ + "feature_index": idx, + "field": "geometry", + "dbf_column": None, + "error_type": "null_geometry", + "value": None, + "message": f"Feature {idx}: geometry is empty.", + }] + + if not shp.is_valid: + reason = explain_validity(shp) + is_self_intersection = "self-intersection" in reason.lower() + return [{ + "feature_index": idx, + "field": "geometry", + "dbf_column": None, + "error_type": "self_intersection" if is_self_intersection else "invalid_geometry", + "value": None, + "message": ( + f"Feature {idx}: polygon is self-intersecting ({reason})." + if is_self_intersection + else f"Feature {idx}: geometry is invalid ({reason})." + ), + }] + + return [] + + def validate_shapefile(shp_path: str) -> _Result: """Validate a single shapefile's attributes against FIELD_MAP rules. @@ -120,7 +186,7 @@ def validate_shapefile(shp_path: str) -> _Result: "message": f"Required column '{dbf_col}' is absent from the shapefile schema.", } for logical, dbf_col in FIELD_MAP.items() - if dbf_col not in schema_props + if logical not in OPTIONAL_FIELDS and dbf_col not in schema_props ] if missing: return False, missing, { @@ -129,9 +195,11 @@ def validate_shapefile(shp_path: str) -> _Result: "fields_with_errors": [e["field"] for e in missing], } - # Value/nullability checks are gated until the official allowed-value - # spec is confirmed; until then a file with all required columns passes. - if not VALIDATE_VALUES: + # Row-level checks are split in two: attribute value/nullability checks + # are gated behind VALIDATE_VALUES (spec not yet confirmed), while + # geometry checks are gated behind VALIDATE_GEOMETRY. When neither is on, + # a file with all required columns passes without scanning any rows. + if not VALIDATE_VALUES and not VALIDATE_GEOMETRY: return True, [], {"total_errors": 0, "capped": False, "fields_with_errors": []} # --- Row-level validation (streaming) -------------------------------- @@ -142,9 +210,16 @@ def validate_shapefile(shp_path: str) -> _Result: capped = True break - feature_errors = _validate_feature(idx, feature.get("properties", {}) or {}) + feature_errors: _ErrorList = [] + if VALIDATE_GEOMETRY: + feature_errors.extend(_validate_geometry(idx, feature.get("geometry"))) + if VALIDATE_VALUES: + feature_errors.extend( + _validate_feature(idx, feature.get("properties", {}) or {}) + ) + errors.extend(feature_errors) - fields_with_errors.update(e["field"] for e in feature_errors) + fields_with_errors.update(e["field"] for e in feature_errors if e["field"]) return ( len(errors) == 0, diff --git a/submit-api/src/submit_api/services/package_service.py b/submit-api/src/submit_api/services/package_service.py index a50fb53a3..396a3173c 100644 --- a/submit-api/src/submit_api/services/package_service.py +++ b/submit-api/src/submit_api/services/package_service.py @@ -33,6 +33,7 @@ from submit_api.services import authorization from submit_api.services.authorization import has_gis_extended_edit_role from submit_api.services.activity_log_service import ActivityLogService +from submit_api.services.geo import GeoService from submit_api.utils.constants import ( MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE, MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE, MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE, MANAGEMENT_PLAN_RESUBMISSION_REQUEST_EMAIL_TEMPLATE, @@ -647,6 +648,11 @@ def submit_package(cls, package_id): permissions=[ProponentPermissionsEnum.SUBMIT_PACKAGE.value], account_project_ids=[package.account_project_id] ) + if GeoService.has_unapproved_uploads_for_package(package_id): + raise BadRequestError( + "All geospatial files must be reviewed and approved before " + "submitting the package to the EAO." + ) if package.submitted_on: submitted_package: PackageModel = cls._resubmit_package(package, session) else: diff --git a/submit-api/src/submit_api/services/submission/__init__.py b/submit-api/src/submit_api/services/submission/__init__.py index 13f6c67e1..510c55486 100644 --- a/submit-api/src/submit_api/services/submission/__init__.py +++ b/submit-api/src/submit_api/services/submission/__init__.py @@ -231,9 +231,24 @@ def update_submission_item_status(cls, item_id, status, session): # Validate required document submissions before marking as COMPLETED if status == ItemStatus.COMPLETED.value: cls._validate_required_document_submission(item_id) + cls._validate_geospatial_uploads_approved(item_id) ItemService.update_submission_item_status(item_id, status, session) + @classmethod + def _validate_geospatial_uploads_approved(cls, item_id): + """Block completion while any geospatial upload for the item is unapproved. + + Prevents bypassing the map-preview approve/reject step by navigating away + (back/refresh) while a file is still processing. Items without geospatial + uploads are unaffected. + """ + if GeoService.has_unapproved_uploads(item_id): + raise BadRequestError( + "Cannot mark item as COMPLETED. All geospatial files must be " + "reviewed and approved before submitting." + ) + @classmethod def _validate_required_document_submission(cls, item_id): """Validate that required document items have at least one document submission.""" diff --git a/submit-api/tests/unit/services/test_geo_validator.py b/submit-api/tests/unit/services/test_geo_validator.py index 639111497..2abf51118 100644 --- a/submit-api/tests/unit/services/test_geo_validator.py +++ b/submit-api/tests/unit/services/test_geo_validator.py @@ -70,8 +70,19 @@ def __iter__(self): return iter(self._features) -def _feature(props: dict) -> dict: - return {"type": "Feature", "geometry": None, "properties": props} +# A minimal valid geometry so attribute-focused tests are not tripped up by the +# geometry validator (which is on by default). +_VALID_GEOMETRY = {"type": "Point", "coordinates": [0.0, 0.0]} + +# A classic self-intersecting "bowtie" polygon. +_SELF_INTERSECTING_GEOMETRY = { + "type": "Polygon", + "coordinates": [[[0.0, 0.0], [1.0, 1.0], [1.0, 0.0], [0.0, 1.0], [0.0, 0.0]]], +} + + +def _feature(props: dict, geometry: dict | None = _VALID_GEOMETRY) -> dict: + return {"type": "Feature", "geometry": geometry, "properties": props} # --------------------------------------------------------------------------- @@ -116,7 +127,7 @@ def test_missing_required_column_fails_fast(): @patch("submit_api.services.geo.validator.VALIDATE_VALUES", True) def test_missing_required_value_fails(): """A null/empty value in a non-nullable field produces a missing_value error.""" - props = {**VALID_PROPERTIES, _DBF["footprint"]: None} + props = {**VALID_PROPERTIES, _DBF["category"]: None} collection = _FakeCollection( schema_props=VALID_SCHEMA, features=[_feature(props)], @@ -126,10 +137,27 @@ def test_missing_required_value_fails(): assert is_valid is False assert any( - e["error_type"] == "missing_value" and e["dbf_column"] == _DBF["footprint"] + e["error_type"] == "missing_value" and e["dbf_column"] == _DBF["category"] for e in errors ) - assert "footprint" in summary["fields_with_errors"] + assert "category" in summary["fields_with_errors"] + + +def test_missing_footprint_column_passes(): + """Footprint is optional: a file lacking the Footprint column still validates.""" + schema_without_footprint = { + k: v for k, v in VALID_SCHEMA.items() if k != _DBF["footprint"] + } + collection = _FakeCollection( + schema_props=schema_without_footprint, + features=[_feature(VALID_PROPERTIES)], + ) + with patch("submit_api.services.geo.validator.fiona.open", return_value=collection): + is_valid, errors, summary = validate_shapefile("dummy.shp") + + assert is_valid is True + assert errors == [] + assert summary["total_errors"] == 0 @patch("submit_api.services.geo.validator.VALIDATE_VALUES", False) @@ -233,3 +261,63 @@ def test_whitespace_stripped_from_valid_value(): assert is_valid is True assert errors == [] + + +# --------------------------------------------------------------------------- +# Geometry validation +# --------------------------------------------------------------------------- + +def test_null_geometry_fails(): + """A feature with no geometry produces a null_geometry error.""" + collection = _FakeCollection( + schema_props=VALID_SCHEMA, + features=[_feature(VALID_PROPERTIES, geometry=None)], + ) + with patch("submit_api.services.geo.validator.fiona.open", return_value=collection): + is_valid, errors, summary = validate_shapefile("dummy.shp") + + assert is_valid is False + assert any(e["error_type"] == "null_geometry" for e in errors) + assert "geometry" in summary["fields_with_errors"] + + +def test_empty_geometry_fails(): + """A feature with an empty geometry produces a null_geometry error.""" + empty_geom = {"type": "Polygon", "coordinates": []} + collection = _FakeCollection( + schema_props=VALID_SCHEMA, + features=[_feature(VALID_PROPERTIES, geometry=empty_geom)], + ) + with patch("submit_api.services.geo.validator.fiona.open", return_value=collection): + is_valid, errors, _ = validate_shapefile("dummy.shp") + + assert is_valid is False + assert any(e["error_type"] == "null_geometry" for e in errors) + + +def test_self_intersecting_polygon_fails(): + """A self-intersecting polygon produces a self_intersection error.""" + collection = _FakeCollection( + schema_props=VALID_SCHEMA, + features=[_feature(VALID_PROPERTIES, geometry=_SELF_INTERSECTING_GEOMETRY)], + ) + with patch("submit_api.services.geo.validator.fiona.open", return_value=collection): + is_valid, errors, summary = validate_shapefile("dummy.shp") + + assert is_valid is False + assert any(e["error_type"] == "self_intersection" for e in errors) + assert "geometry" in summary["fields_with_errors"] + + +@patch("submit_api.services.geo.validator.VALIDATE_GEOMETRY", False) +def test_geometry_validation_disabled_skips_geometry_checks(): + """With VALIDATE_GEOMETRY off, a null geometry no longer fails the file.""" + collection = _FakeCollection( + schema_props=VALID_SCHEMA, + features=[_feature(VALID_PROPERTIES, geometry=None)], + ) + with patch("submit_api.services.geo.validator.fiona.open", return_value=collection): + is_valid, errors, _ = validate_shapefile("dummy.shp") + + assert is_valid is True + assert errors == [] diff --git a/submit-web/src/components/App/DocumentUpload/DocumentTable.tsx b/submit-web/src/components/App/DocumentUpload/DocumentTable.tsx index 5dd225cf4..5f52e8d5b 100644 --- a/submit-web/src/components/App/DocumentUpload/DocumentTable.tsx +++ b/submit-web/src/components/App/DocumentUpload/DocumentTable.tsx @@ -66,15 +66,12 @@ export default function DocumentTable({ Uploaded by Version - {isGeoSpatial && ( - Status - )} Actions - + ))} diff --git a/submit-web/src/components/App/DocumentUpload/DocumentTableRow.tsx b/submit-web/src/components/App/DocumentUpload/DocumentTableRow.tsx index 260643606..4269595fe 100644 --- a/submit-web/src/components/App/DocumentUpload/DocumentTableRow.tsx +++ b/submit-web/src/components/App/DocumentUpload/DocumentTableRow.tsx @@ -1,12 +1,12 @@ import React, { useState } from "react"; import { + Box, styled, TableCell, TableRow, TableRowProps, Typography, } from "@mui/material"; -import { StatusChip } from "@/components/Shared/StatusChip"; import { BCDesignTokens } from "epic.theme"; import { deleteDocument } from "@/hooks/api/useObjectStorage"; import { notify } from "@/components/Shared/Snackbar/snackbarStore"; @@ -15,10 +15,9 @@ import { LoadingButton } from "@/components/Shared/LoadingButton"; import { useDeleteSubmission } from "@/hooks/api/useSubmissions"; import { useFileStore } from "@/store/fileStore"; import { useFormContext } from "react-hook-form"; -import { useParams } from "@tanstack/react-router"; import { getObjectFromS3 } from "@/components/Shared/Table/utils"; import { DocumentLink } from "@/components/Shared/DocumentLink"; -import { useGetGeoUploads } from "@/hooks/api/useGeo"; +import { GeoApprovedBadge } from "@/components/Shared/GeoApprovedBadge"; export const StyledHeadTableCell = styled(TableCell, { shouldForwardProp: (prop) => prop !== "error", @@ -98,14 +97,12 @@ type DocumentTableRowProps = Readonly<{ error?: boolean; formFieldName?: string; folder?: string; - isGeoSpatial?: boolean; onDocumentClick?: (documentItem: Submission) => void; }>; export default function DocumentTableRow({ documentItem, error = false, formFieldName, - isGeoSpatial, onDocumentClick, }: DocumentTableRowProps) { const { submitted_by, version, submitted_document } = documentItem; @@ -113,15 +110,6 @@ export default function DocumentTableRow({ const [isRemovingDocument, setIsRemovingDocument] = useState(false); const { setValue, trigger, getValues } = useFormContext(); // Get form context directly const { removeFile } = useFileStore(); - const { submissionId: subItemId } = useParams({ - from: "/proponent/_proponentLayout/projects/$projectId/_projectLayout/submission-packages/$submissionPackageId/_submissionLayout/submissions/$submissionId", - }); - const { data: geoUploads } = useGetGeoUploads({ itemId: Number(subItemId) }); - - const geoUpload = (geoUploads as any[])?.find( - (u: any) => u.raw_s3_key === submitted_document?.url, - ); - const { mutateAsync: deleteSubmission } = useDeleteSubmission({ submissionItemId: documentItem.item_id, }); @@ -177,52 +165,45 @@ export default function DocumentTableRow({ error={error} > - - onDocumentClick(documentItem) - : downloadDocument - } - loading={pendingGetObject} + + onDocumentClick(documentItem) + : downloadDocument + } + loading={pendingGetObject} + /> + + - + {submitted_by} {version} - {isGeoSpatial && ( - - {geoUpload ? ( - - ) : ( - - N/A - - )} - - )} document.folder === section.folder, ); + const isGeoSpatialSection = section.name === "geospatial"; + const sectionMaxSize = isGeoSpatialSection + ? GEO_MAX_FILE_SIZE_BYTES + : undefined; + const sectionMaxSizeMb = isGeoSpatialSection + ? GEO_MAX_FILE_SIZE_MB + : DEFAULT_MAX_FILE_SIZE_MB; + return ( @@ -215,6 +226,7 @@ export const GenericDocumentUploadSection: React.FC< } maxFiles={section.maxFiles} maxFilesErrorMessage={section.maxFilesErrorMessage} + maxSize={sectionMaxSize} accept={fileUploadAccept} /> Accepted file types: {acceptedFileTypes.join(", ")}. Max file - size: 500 MB. + size: {sectionMaxSizeMb} MB. diff --git a/submit-web/src/components/App/DocumentUpload/PendingDocumentRow.tsx b/submit-web/src/components/App/DocumentUpload/PendingDocumentRow.tsx index 7d726553c..c60675c5c 100644 --- a/submit-web/src/components/App/DocumentUpload/PendingDocumentRow.tsx +++ b/submit-web/src/components/App/DocumentUpload/PendingDocumentRow.tsx @@ -139,13 +139,6 @@ export default function PendingDocumentRow({ - {isGeoSpatial && ( - - - Uploading... - - - )} ); diff --git a/submit-web/src/components/App/Map/MapPreviewModal.tsx b/submit-web/src/components/App/Map/MapPreviewModal.tsx index 43700a1d3..aface1af3 100644 --- a/submit-web/src/components/App/Map/MapPreviewModal.tsx +++ b/submit-web/src/components/App/Map/MapPreviewModal.tsx @@ -7,7 +7,11 @@ import Map, { } from "react-map-gl/maplibre"; import "maplibre-gl/dist/maplibre-gl.css"; import { getJsonObject } from "@/hooks/api/useObjectStorage"; -import { useGetGeoUploadUrl, GeoUploadUrlResponse } from "@/hooks/api/useGeo"; +import { + useGetGeoUploadUrl, + GeoUploadUrlResponse, + GeoValidationError, +} from "@/hooks/api/useGeo"; import { LoadingButton } from "@/components/Shared/LoadingButton"; import { Alert, @@ -27,9 +31,11 @@ import { } from "@mui/material"; import SatelliteIcon from "@mui/icons-material/Satellite"; import MapIcon from "@mui/icons-material/Map"; +import HighlightOffIcon from "@mui/icons-material/HighlightOff"; import type { GeoJSON } from "geojson"; import { Submission } from "@/models/Submission"; import { BCDesignTokens } from "epic.theme"; +import { GeoSpatialGuidelines } from "@/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialGuidelines"; interface MapPreviewModalProps { open: boolean; @@ -38,6 +44,8 @@ interface MapPreviewModalProps { fileSizeKb?: number; status?: string; errorMessage?: string; + validationErrors?: GeoValidationError[]; + isApproved?: boolean; onApprove?: () => void; onReject?: () => Promise; onClose?: () => void; @@ -51,10 +59,12 @@ export const MapPreviewModal: React.FC = ({ fileSizeKb, status, errorMessage, + validationErrors, + isApproved, onApprove, onReject, onClose, - previewOnly = false, + previewOnly, }) => { const mapRef = useRef(null); const [loading, setLoading] = useState(false); @@ -160,6 +170,41 @@ export const MapPreviewModal: React.FC = ({ }, [metaData, geoJson]); const isProcessing = status === "processing" || (!uploadId && open); + const isValidationFailure = status === "validation_failed"; + const isFailure = status === "failed" || isValidationFailure; + const isTimeout = + status === "failed" && + (errorMessage?.toLowerCase().includes("timed out") ?? false); + + // Columns the shapefile is missing. Prefer the structured validation errors, + // but fall back to parsing the human-readable error_message (e.g. "Validation + // failed: missing required column(s): Category, Subcategor, ...") so the + // bulleted list still renders when only the message string is available. + const structuredMissingColumns = (validationErrors ?? []) + .filter((e) => e.error_type === "missing_column") + .map((e) => e.dbf_column ?? e.field) + .filter((c): c is string => Boolean(c)); + + const parseMissingColumns = (message?: string): string[] => { + const match = message?.match(/missing required column\(s\):\s*([^;.]+)/i); + if (!match) return []; + return match[1] + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + }; + + const missingColumns = + structuredMissingColumns.length > 0 + ? structuredMissingColumns + : parseMissingColumns(errorMessage); + + const [displayApproved, setDisplayApproved] = useState(false); + useEffect(() => { + if (open) { + setDisplayApproved(Boolean(isApproved)); + } + }, [open, isApproved]); const handleReject = async () => { if (!onReject) return; @@ -174,7 +219,7 @@ export const MapPreviewModal: React.FC = ({ return ( = ({ {/* Alert Banner */} - - - Please verify this geospatial file - Review the map preview and file information to ensure this is the correct data (correct location, using the BC Albers Projection). - You must manually approve or reject each file before proceeding. - - + {!isApproved && !isFailure && ( + + + Please verify this geospatial file + Review the map preview and file information to ensure this is the correct data (correct location, using the BC Albers Projection). + You must manually approve or reject each file before proceeding. + + + )} {/* Dynamic Map Container */} - {(status === "failed" || status === "validation_failed") ? ( + {isFailure ? ( - - {status === "validation_failed" - ? "Attribute validation failed" - : "Preview not available for this file"} - - - {status === "validation_failed" - ? "This file does not meet the required attribute standards. Please correct the issue and re-upload." - : "Geometry must be valid; no NULL geometry or self-intersecting polygons"} - - {errorMessage && ( - - {errorMessage} - + + {isValidationFailure ? ( + <> + + Attribute validation failed + + {missingColumns.length > 0 ? ( + <> + + We couldn't process this file because the shapefile + is missing the following required column(s): + + + {missingColumns.map((column) => ( + + {column} + + ))} + + + ) : ( + + {errorMessage || + "This file does not meet the required attribute standards."} + + )} + + + ) : isTimeout ? ( + <> + + We apologize. The file upload timed out. + + + Please try uploading the file again. + + + ) : ( + <> + + Geospatial processing failed + + + {errorMessage || + "We couldn't process this file. The geometry must be valid, with no NULL or self-intersecting geometry."} + + + )} ) : ( @@ -556,9 +680,20 @@ export const MapPreviewModal: React.FC = ({ - {previewOnly ? ( + {isFailure && !previewOnly ? ( + // Failed upload/processing: the file is unusable, so the only action is + // to close, which fully removes the file when a removal handler exists. + + Close + + ) : previewOnly ? ( + {displayApproved ? ( + + ) : ( + + )} )} diff --git a/submit-web/src/components/App/Submission/DocumentRow/index.tsx b/submit-web/src/components/App/Submission/DocumentRow/index.tsx index 2c5269582..10ac5d0e9 100644 --- a/submit-web/src/components/App/Submission/DocumentRow/index.tsx +++ b/submit-web/src/components/App/Submission/DocumentRow/index.tsx @@ -1,4 +1,4 @@ -import { Box, IconButton, TableRow, Typography, Button, Tooltip } from "@mui/material"; +import { Box, IconButton, TableRow, Typography, Tooltip } from "@mui/material"; import { Submission, SUBMISSION_STATUS } from "@/models/Submission"; import { SubmissionItem } from "@/models/SubmissionItem"; import { @@ -16,6 +16,7 @@ import { ActionButton } from "./ActionButton"; import PermissionsGate from "@/components/Shared/PermissionGate"; import { SubmissionPackage, PackageType } from "@/models/Package"; import { DocumentLink } from "@/components/Shared/DocumentLink"; +import { GeoApprovedBadge } from "@/components/Shared/GeoApprovedBadge"; import ActionSplitButton, { SplitButtonAction, } from "@/components/Shared/ActionSplitButton/ActionSplitButton"; @@ -23,11 +24,10 @@ import { BCDesignTokens } from "epic.theme"; import { useDocumentRow } from "@/hooks/useDocumentRow"; import { usePackageRoles } from "@/hooks/usePackageRoles"; import { useState } from "react"; +import { useAccount } from "@/store/accountStore"; import { lazy, Suspense } from "react"; import { GIS_ITEM_TYPE_NAME } from "@/utils/constants"; import { useGetGeoUploads } from "@/hooks/api/useGeo"; -import { useHasRole } from "@/hooks/common"; -import { EPIC_SUBMIT_ROLE } from "@/models/Role"; const MapPreviewModal = lazy(() => import("@/components/App/Map/MapPreviewModal").then((m) => ({ @@ -54,19 +54,20 @@ export default function DocumentRow({ documentSubmission; const packageType = propsPackageType || submissionPackage?.type; const name = submitted_document?.name || ""; - + const { roles } = useAccount(); + // Check if this is a GIS document (item type name "Geospatial Information") const isGISDocument = submissionItem.type.name === GIS_ITEM_TYPE_NAME; - + // Get the correct package-specific roles (include document folder for GIS handling) const packageRoles = usePackageRoles(submissionPackage, packageType, submitted_document?.folder); - - // Check if user has GIS permissions (includes full_access check) - const hasGISPermissions = useHasRole(EPIC_SUBMIT_ROLE.gis_extended_edit); - + + // Check if user has GIS permissions + const hasGISPermissions = roles?.includes('gis_extended_edit') || roles?.includes('full_access'); + // State for GIS preview modal const [showPreviewModal, setShowPreviewModal] = useState(false); - + // Get geo uploads for GIS preview const { data: geoUploads } = useGetGeoUploads({ itemId: submissionItem.id, @@ -176,7 +177,15 @@ export default function DocumentRow({ ]} > - + {staff ? ( @@ -203,34 +211,13 @@ export default function DocumentRow({ /> )} - {isGISDocument && staff && ( - - )} - - + + + {submitted_by || ""} @@ -332,11 +319,11 @@ export default function DocumentRow({ {} // Disabled click + onClick: () => { } // Disabled click }} secondaryActions={splitButtonConfig.secondary.map(action => ({ ...action, - onClick: () => {} // Disabled click + onClick: () => { } // Disabled click }))} disabled /> @@ -357,7 +344,7 @@ export default function DocumentRow({ ) : null} - + {expanded && ( - )} - + ) + } + {/* GIS Preview Modal */} - {isGISDocument && ( - - u.raw_s3_key === documentSubmission.submitted_document?.url)?.id ?? null} - documentItem={documentSubmission} - fileSizeKb={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.file_size_kb} - status={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.status} - errorMessage={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.error_message} - previewOnly={true} - onClose={() => setShowPreviewModal(false)} - /> - - )} + { + isGISDocument && ( + + u.raw_s3_key === documentSubmission.submitted_document?.url)?.id ?? null} + documentItem={documentSubmission} + fileSizeKb={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.file_size_kb} + status={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.status} + errorMessage={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.error_message} + validationErrors={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.validation_errors} + previewOnly={true} + onClose={() => setShowPreviewModal(false)} + /> + + ) + } ); } diff --git a/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx b/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx index 751a8281f..28f1619b5 100644 --- a/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx +++ b/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx @@ -15,7 +15,7 @@ type ItemsTableHeadProps = Readonly<{ export default function ItemsTableHead({ approvalType }: ItemsTableHeadProps) { const { userType } = useAccount(); const isStaffUser = userType === USER_TYPE.STAFF; - + const getSvgForApprovalType = () => { if (!approvalType) return null; return approvalType === SubmissionPackageApprovalType.A ? TypeASvg : TypeCSvg; diff --git a/submit-web/src/components/App/SubmissionItem/DocumentsTable/AddDocumentActionButton.tsx b/submit-web/src/components/App/SubmissionItem/DocumentsTable/AddDocumentActionButton.tsx index 9f3898f1b..145c96d78 100644 --- a/submit-web/src/components/App/SubmissionItem/DocumentsTable/AddDocumentActionButton.tsx +++ b/submit-web/src/components/App/SubmissionItem/DocumentsTable/AddDocumentActionButton.tsx @@ -5,6 +5,7 @@ import { QUERY_KEY } from "@/hooks/api/constants"; import { saveObject } from "@/hooks/api/useObjectStorage"; import { createSubmission } from "@/hooks/api/useSubmissions"; import { Submission, SUBMISSION_TYPE } from "@/models/Submission"; +import { GEO_MAX_FILE_SIZE_BYTES, GEO_MAX_FILE_SIZE_MB } from "@/utils/constants"; import { useQueryClient } from "@tanstack/react-query"; import { useParams } from "@tanstack/react-router"; import { isAxiosError } from "axios"; @@ -41,6 +42,12 @@ export const AddDocumentActionButton = ({ notify.error("Only .zip and .shp files are allowed for geospatial data."); return; } + if (isGeoSpatial && fileToUpload.size > GEO_MAX_FILE_SIZE_BYTES) { + notify.error( + `Geospatial files must be ${GEO_MAX_FILE_SIZE_MB} MB or smaller.`, + ); + return; + } try { setIsAddingDocument(true); setIsPendingUpload(true); diff --git a/submit-web/src/components/App/SubmissionItem/DocumentsTable/Row.tsx b/submit-web/src/components/App/SubmissionItem/DocumentsTable/Row.tsx index d53c81f43..6d79ce666 100644 --- a/submit-web/src/components/App/SubmissionItem/DocumentsTable/Row.tsx +++ b/submit-web/src/components/App/SubmissionItem/DocumentsTable/Row.tsx @@ -1,5 +1,4 @@ -import { TableRow, IconButton, Typography } from "@mui/material"; -import { StatusChip } from "@/components/Shared/StatusChip"; +import { Box, TableRow, IconButton } from "@mui/material"; import { Submission, SUBMISSION_STATUS, @@ -18,6 +17,7 @@ import { deleteDocument, saveObject } from "@/hooks/api/useObjectStorage"; import { FileUploadButton } from "@/components/Shared/FileUploadButton"; import { isAxiosError } from "axios"; import { DocumentLink } from "@/components/Shared/DocumentLink"; +import { GeoApprovedBadge } from "@/components/Shared/GeoApprovedBadge"; import { LoadingButton } from "@/components/Shared/LoadingButton"; import { BCDesignTokens } from "epic.theme"; import { useFileStore } from "@/store/fileStore"; @@ -25,7 +25,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { QUERY_KEY } from "@/hooks/api/constants"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import DocumentsSubTable from "@/components/App/Submission/ItemsTable/DocumentsSubTable"; -import { useGetGeoUploads } from "@/hooks/api/useGeo"; +import { GEO_MAX_FILE_SIZE_BYTES, GEO_MAX_FILE_SIZE_MB } from "@/utils/constants"; type DocumentRowProps = Readonly<{ documentSubmission: Submission; @@ -43,7 +43,7 @@ export default function Row({ onDocumentClick, }: DocumentRowProps) { const queryClient = useQueryClient(); - const { submissionPackageId, submissionId: subItemId } = useParams({ + const { submissionPackageId } = useParams({ from: "/proponent/_proponentLayout/projects/$projectId/_projectLayout/submission-packages/$submissionPackageId/_submissionLayout/submissions/$submissionId", }); const [isRemovingDocument, setIsRemovingDocument] = useState(false); @@ -55,20 +55,6 @@ export default function Row({ const { removeFile } = useFileStore(); - const { data: geoUploads } = useGetGeoUploads( - { - itemId: Number(subItemId), - autoRefetch: isGeoSpatial, - }, - { - enabled: isGeoSpatial, - } - ); - - const geoUpload = (geoUploads as any[])?.find( - (u: any) => u.raw_s3_key === currentSubmission.submitted_document?.url, - ); - const { mutateAsync: deleteSubmission } = useDeleteSubmission({ submissionItemId: currentSubmission.item_id, onSuccess: () => { @@ -139,6 +125,12 @@ export default function Row({ notify.error("Only .zip and .shp files are allowed for geospatial data."); return; } + if (isGeoSpatial && fileToUpload.size > GEO_MAX_FILE_SIZE_BYTES) { + notify.error( + `Geospatial files must be ${GEO_MAX_FILE_SIZE_MB} MB or smaller.`, + ); + return; + } try { setIsReplacingDocument(true); setIsPendingUpload(true); @@ -203,11 +195,25 @@ export default function Row({ <> - + + + + {submitted_by || ""} @@ -225,27 +231,6 @@ export default function Row({ )} - {isGeoSpatial && ( - - {geoUpload ? ( - - ) : ( - - N/A - - )} - - )} {isRemovable ? ( Uploaded by Version - {isGeoSpatial && ( - Status - )} Actions @@ -116,7 +113,7 @@ export default function DocumentsTable({ {getSubmissionItemLabel(submissionItem.type.name)} - + { +export const GeoSpatialGuidelines = ({ isPreview }: { isPreview?: boolean }) => { return ( @@ -16,7 +16,8 @@ export const GeoSpatialGuidelines = () => { > Spatial Data Submission Guideline {" "} - (PDF, 5.1MB) to understand GIS files requirements. You will also find in this document some file templates to get you started. + (PDF, 5.1MB) to understand GIS files requirements. {" "} + {isPreview ? `Please review this document and re-upload.` : `You can also find file templates in this document to get you started.`} ); diff --git a/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialProponentView.tsx b/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialProponentView.tsx index 268b93760..661648af8 100644 --- a/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialProponentView.tsx +++ b/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialProponentView.tsx @@ -8,7 +8,7 @@ import { GenericDocumentUploadSection, UploadSectionConfig, } from "@/components/App/DocumentUpload/GenericDocumentUploadSection"; -import { useCallback, useMemo, useState, lazy, Suspense } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, lazy, Suspense } from "react"; import Form from "@/components/Shared/Forms/common"; import { FormProvider, useForm } from "react-hook-form"; import { Submission } from "@/models/Submission"; @@ -32,7 +32,7 @@ import { useGetSubmissionPackage } from "@/hooks/api/usePackages"; import { notify } from "@/components/Shared/Snackbar/snackbarStore"; import { isAxiosError } from "axios"; import SubmissionActionButtons from "@/components/App/SubmissionItem/SubmissionActionButtons"; -import { useGetGeoUploads, GeoUpload } from "@/hooks/api/useGeo"; +import { useGetGeoUploads, useApproveGeoUpload, GeoUpload } from "@/hooks/api/useGeo"; import { useFileStore } from "@/store/fileStore"; // Lazy-load the map modal so maplibre-gl is not downloaded until first use const MapPreviewModal = lazy(() => @@ -88,6 +88,7 @@ export const GeoSpatialProponentView = () => { const { mutateAsync: deleteSubmissionAsync } = useDeleteSubmission({ submissionItemId: Number(submissionItemId), }); + const { mutateAsync: approveGeoUpload } = useApproveGeoUpload(); const { removeFile } = useFileStore(); const documentSubmissions = submissionItem?.submissions?.filter( @@ -181,9 +182,20 @@ export const GeoSpatialProponentView = () => { [previewDocument], ); - const handleApprove = useCallback(() => { + const handleApprove = useCallback(async () => { + // Persist approval server-side so navigating away (back/refresh) cannot + // bypass the review step — the item can't be completed until every upload + // is approved. + if (previewUpload) { + try { + await approveGeoUpload(previewUpload.id); + } catch { + notify.error("Failed to approve geospatial file"); + return; + } + } openNextInQueue(reviewQueue); - }, [reviewQueue, openNextInQueue]); + }, [previewUpload, reviewQueue, openNextInQueue, approveGeoUpload]); const handleReject = useCallback(async () => { if (!previewDocument) return; @@ -220,8 +232,47 @@ export const GeoSpatialProponentView = () => { openNextInQueue, ]); - // Save & Exit is blocked while the review modal is open or files are queued - const isReviewPending = previewDocument !== null || reviewQueue.length > 0; + // On return to the page (e.g. after a back/refresh during processing), re-open + // the review modal for any upload the proponent never approved. Runs once per + // mount; new uploads within the session are handled by onUploadComplete. + const didSeedReview = useRef(false); + useEffect(() => { + if (didSeedReview.current) return; + if (!uploads || !documentSubmissions) return; + + const unapproved = uploads.filter((u) => !u.is_approved); + if (unapproved.length === 0) { + didSeedReview.current = true; + return; + } + + const docs = unapproved + .map((upload) => + documentSubmissions.find( + (s) => s.submitted_document?.url === upload.raw_s3_key, + ), + ) + .filter((d): d is Submission => Boolean(d)); + + // Wait until the matching document submissions are available in cache. + if (docs.length < unapproved.length) return; + + didSeedReview.current = true; + const [first, ...rest] = docs; + setPreviewDocument(first); + setReviewQueue(rest); + }, [uploads, documentSubmissions]); + + // Save & Exit is blocked while a review is open/queued, or any upload for this + // item is still unapproved (the server enforces the same rule on submit). + const hasUnapprovedUploads = useMemo( + () => (uploads ?? []).some((u) => !u.is_approved), + [uploads], + ); + const isReviewPending = + previewDocument !== null || + reviewQueue.length > 0 || + hasUnapprovedUploads; const documentUploadSections: UploadSectionConfig[] = useMemo( () => [ @@ -283,8 +334,11 @@ export const GeoSpatialProponentView = () => { (previewDocument !== null ? "processing" : undefined) } errorMessage={previewUpload?.error_message} + validationErrors={previewUpload?.validation_errors} + isApproved={previewUpload?.is_approved} onApprove={handleApprove} onReject={handleReject} + onClose={() => openNextInQueue(reviewQueue)} /> diff --git a/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialStaffView.tsx b/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialStaffView.tsx index 8e9466446..d5337a436 100644 --- a/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialStaffView.tsx +++ b/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialStaffView.tsx @@ -197,6 +197,7 @@ export const GeoSpatialStaffView = () => { fileSizeKb={previewUpload?.file_size_kb} status={previewUpload?.status} errorMessage={previewUpload?.error_message} + validationErrors={previewUpload?.validation_errors} fileIndex={previewIndex} totalFiles={totalGeoFiles} onClose={() => { diff --git a/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialUpdateForm/index.tsx b/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialUpdateForm/index.tsx index 1440016fa..08e9c955f 100644 --- a/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialUpdateForm/index.tsx +++ b/submit-web/src/components/App/SubmissionItem/GeoSpatialInformation/GeoSpatialUpdateForm/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, lazy, Suspense } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, lazy, Suspense } from "react"; import { Box, Button, Typography, Grid } from "@mui/material"; import { SubmissionFormContainer } from "@/components/App/SubmissionItem/SubmissionFormContainer"; import { useNavigate, useParams } from "@tanstack/react-router"; @@ -6,8 +6,9 @@ import { deleteDocument, S3_FOLDER } from "@/hooks/api/useObjectStorage"; import DocumentsTable from "@/components/App/SubmissionItem/DocumentsTable"; import { UnfinishedUploadsCheck } from "@/components/Shared/UnfinishedUploadsCheck"; import { BCDesignTokens } from "epic.theme"; -import { useGetGeoUploads, GeoUpload } from "@/hooks/api/useGeo"; -import { Submission } from "@/models/Submission"; +import { useGetGeoUploads, useApproveGeoUpload, GeoUpload } from "@/hooks/api/useGeo"; +import { Submission, SUBMISSION_TYPE } from "@/models/Submission"; +import { SubmissionItem } from "@/models/SubmissionItem"; import { useQueryClient } from "@tanstack/react-query"; import { QUERY_KEY } from "@/hooks/api/constants"; import { notify } from "@/components/Shared/Snackbar/snackbarStore"; @@ -31,6 +32,7 @@ export const GeoSpatialUpdateForm = () => { const queryClient = useQueryClient(); const [isPendingUpload, setIsPendingUpload] = useState(false); const [previewDocument, setPreviewDocument] = useState(null); + const [reviewQueue, setReviewQueue] = useState([]); const { data: geoUploads } = useGetGeoUploads({ itemId: Number(submissionItemId), @@ -38,6 +40,23 @@ export const GeoSpatialUpdateForm = () => { }); const uploads = geoUploads as unknown as GeoUpload[]; + const { mutateAsync: approveGeoUpload } = useApproveGeoUpload(); + + const submissionItem = queryClient.getQueryData([ + QUERY_KEY.SUBMISSION_ITEM, + Number(submissionItemId), + ]); + const documentSubmissions = submissionItem?.submissions?.filter( + (submission) => submission.type === SUBMISSION_TYPE.DOCUMENT, + ); + + // Advances to the next file awaiting review + const openNextInQueue = useCallback((queue: Submission[]) => { + const [next, ...remaining] = queue; + setReviewQueue(remaining); + setPreviewDocument(next ?? null); + }, []); + // Derive previewUpload reactively so the modal updates as polling resolves status const previewUpload = useMemo(() => { if (!previewDocument || !uploads) return null; @@ -78,11 +97,24 @@ export const GeoSpatialUpdateForm = () => { // Called by AddDocumentActionButton once a geo file finishes uploading const onUploadComplete = (submission: Submission) => { - setPreviewDocument(submission); + if (previewDocument !== null) { + setReviewQueue((prev) => [...prev, submission]); + } else { + setPreviewDocument(submission); + } }; - const handleApprove = () => { - setPreviewDocument(null); + const handleApprove = async () => { + // Persist approval so back/refresh cannot bypass the review step. + if (previewUpload) { + try { + await approveGeoUpload(previewUpload.id); + } catch { + notify.error("Failed to approve geospatial file."); + return; + } + } + openNextInQueue(reviewQueue); }; const handleReject = async () => { @@ -99,11 +131,42 @@ export const GeoSpatialUpdateForm = () => { } catch { notify.error("Failed to remove geospatial file."); } finally { - setPreviewDocument(null); + openNextInQueue(reviewQueue); } }; - const isBlockedFromExit = isPendingUpload || previewDocument !== null; + // Re-open review for any upload left unapproved after a back/refresh. Runs + // once per mount; in-session uploads are handled by onUploadComplete. + const didSeedReview = useRef(false); + useEffect(() => { + if (didSeedReview.current) return; + if (!uploads || !documentSubmissions) return; + + const unapproved = uploads.filter((u) => !u.is_approved); + if (unapproved.length === 0) { + didSeedReview.current = true; + return; + } + + const docs = unapproved + .map((upload) => + documentSubmissions.find( + (s) => s.submitted_document?.url === upload.raw_s3_key, + ), + ) + .filter((d): d is Submission => Boolean(d)); + + if (docs.length < unapproved.length) return; + + didSeedReview.current = true; + const [first, ...rest] = docs; + setPreviewDocument(first); + setReviewQueue(rest); + }, [uploads, documentSubmissions]); + + const hasUnapprovedUploads = (uploads ?? []).some((u) => !u.is_approved); + const isBlockedFromExit = + isPendingUpload || previewDocument !== null || hasUnapprovedUploads; return ( @@ -147,8 +210,10 @@ export const GeoSpatialUpdateForm = () => { (previewDocument !== null ? "processing" : undefined) } errorMessage={previewUpload?.error_message} + isApproved={previewUpload?.is_approved} onApprove={handleApprove} onReject={handleReject} + onClose={() => openNextInQueue(reviewQueue)} /> diff --git a/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx b/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx index 80f2ae853..f63a4497e 100644 --- a/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx +++ b/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx @@ -20,11 +20,13 @@ export type SplitButtonAction = { type ActionSplitButtonProps = Readonly<{ primaryAction: SplitButtonAction; secondaryActions: SplitButtonAction[]; + disabled?: boolean; }>; export default function ActionSplitButton({ primaryAction, secondaryActions, + disabled = false, }: ActionSplitButtonProps) { const [menuOpen, setMenuOpen] = useState(false); const anchorRef = useRef(null); @@ -41,6 +43,7 @@ export default function ActionSplitButton({