Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0b9d97d
[NDR-359] Initial lambda setup
jameslinnell Jan 15, 2026
6e07638
[NDR-359] Some formatting
jameslinnell Jan 27, 2026
1e7246c
[NDR-359] Remove dup code
jameslinnell Jan 27, 2026
c5adffb
[NDR-359] Update unit tests for delete fhir
jameslinnell Jan 27, 2026
8fc6cee
[NDR-359] Correct DB name
jameslinnell Jan 27, 2026
0316131
[NDR-359] Remove comments
jameslinnell Jan 27, 2026
38719d1
[NDR-359] Fix to the deployment from dev container script.
jameslinnell Jan 27, 2026
493d2f7
[NDR-359] Rename skip main var
jameslinnell Jan 27, 2026
b3a3292
[NDR-359] Fix deployment name lookup
jameslinnell Jan 27, 2026
3ef7d2c
[NDR-359] Change how it gets the title of the workflow
jameslinnell Jan 27, 2026
5c2ffd2
[NDR-359] Remove LG comment
jameslinnell Jan 29, 2026
2def17b
[NDR-359] Move delete_document_reference helper to conftest
jameslinnell Jan 29, 2026
95c895c
[NDR-359] Stricter query params for FHIR deletion.
jameslinnell Feb 3, 2026
5db66d5
[NDR-359] L
jameslinnell Feb 3, 2026
d6b60f3
[NDR-359] Update e2e tests
jameslinnell Feb 3, 2026
e9b3e00
[NDR-359] LG FHIR failure e2e tests
jameslinnell Feb 3, 2026
0b576e9
[NDR-359] Allow FHIR in document_deletion_service
jameslinnell Feb 3, 2026
497cf50
[NDR-359] Fix unit test
jameslinnell Feb 3, 2026
9d195e4
[NDR-359] test document delete service for FHIR
jameslinnell Feb 3, 2026
688c339
[NDR-359] Sonarcloud clean up
jameslinnell Feb 3, 2026
fb13916
[NDR-359] PR changes
jameslinnell Feb 6, 2026
efa6a38
[NDR-359] Remove pathParameters check
jameslinnell Feb 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/base-lambdas-reusable-deploy-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,20 @@ jobs:
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}

deploy_fhir_document_reference_delete_lambda:
name: Deploy Delete Document References FHIR Lambda
uses: ./.github/workflows/base-lambdas-reusable-deploy.yml
with:
environment: ${{ inputs.environment}}
python_version: ${{ inputs.python_version }}
build_branch: ${{ inputs.build_branch}}
sandbox: ${{ inputs.sandbox }}
lambda_handler_name: delete_fhir_document_reference_handler
lambda_aws_name: DeleteDocumentReferencesFHIR
lambda_layer_names: "core_lambda_layer"
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}

deploy_alerting_lambda:
name: Deploy Alerting lambda
uses: ./.github/workflows/base-lambdas-reusable-deploy.yml
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ build-and-deploy-sandbox: ## Build a sandbox and deploy code. If no SANDBOX_NAME
$(if $(SANDBOX_NAME),--sandbox_name=$(SANDBOX_NAME)) \
$(if $(BUILD_INFRA),--build_infra=$(BUILD_INFRA)) \
$(if $(FULL_DEPLOY),--full_deploy=$(FULL_DEPLOY)) \
$(if $(SKIP_MAIN),--skip_main=$(SKIP_MAIN)) \
$(if $(NDRI_DIR_LOC_OVERRIDE),--ndri_dir_loc_override=$(NDRI_DIR_LOC_OVERRIDE))

download-api-certs: ## Downloads mTLS certificates (use with dev envs only). Usage: make download-api-certs WORKSPACE=<workspace>
Expand Down
1 change: 1 addition & 0 deletions lambdas/enums/logging_app_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class LoggingAppInteraction(Enum):
VIEW_LG_RECORD = "View LG record"
DOWNLOAD_RECORD = "Download a record"
DELETE_RECORD = "Delete a record"
FHIR_DELETE_RECORD = "Delete a FHIR record"
UPLOAD_RECORD = "Upload a record"
UPDATE_RECORD = "Update a record"
STITCH_RECORD = "Stitch a record"
Expand Down
70 changes: 70 additions & 0 deletions lambdas/handlers/delete_fhir_document_reference_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from enums.fhir.fhir_issue_type import FhirIssueCoding
from enums.lambda_error import LambdaError
from enums.logging_app_interaction import LoggingAppInteraction
from services.delete_fhir_document_reference_service import (
DeleteFhirDocumentReferenceService,
)
from utils.audit_logging_setup import LoggingService
from utils.decorators.ensure_env_var import ensure_environment_variables
from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions_fhir
from utils.decorators.set_audit_arg import set_request_context_for_logging
from utils.error_response import ErrorResponse
from utils.exceptions import FhirDocumentReferenceException
from utils.lambda_response import ApiGatewayResponse
from utils.request_context import request_context

logger = LoggingService(__name__)


@handle_lambda_exceptions_fhir
@set_request_context_for_logging
@ensure_environment_variables(
names=[
"DOCUMENT_STORE_DYNAMODB_NAME",
"LLOYD_GEORGE_DYNAMODB_NAME",
"UNSTITCHED_LLOYD_GEORGE_DYNAMODB_NAME",
]
)
def lambda_handler(event, context):
try:
request_context.app_interaction = LoggingAppInteraction.FHIR_DELETE_RECORD.value
logger.info("Processing FHIR document reference DELETE request")
fhir_doc_ref_service = DeleteFhirDocumentReferenceService()

fhir_response = fhir_doc_ref_service.process_fhir_document_reference(event)

if fhir_response:
logger.info(
"FHIR Documents were deleted successfully",
{"Result": "Successful deletion"},
)
return ApiGatewayResponse(
status_code=204, methods="DELETE"
).create_api_gateway_response()
else:
logger.error(
LambdaError.DocDelNull.to_str(),
{
"Result": "No matching documents available during FHIR DELETE request"
},
)

return ApiGatewayResponse(
status_code=404,
body=ErrorResponse(
"404",
"No Documents found for deletion.",
getattr(request_context, "request_id", None),
).create_error_fhir_response(FhirIssueCoding.NOT_FOUND),
methods="DELETE",
).create_api_gateway_response()

except FhirDocumentReferenceException as exception:
logger.error(f"Error processing FHIR document reference: {str(exception)}")
return ApiGatewayResponse(
status_code=exception.status_code,
body=exception.error.create_error_response().create_error_fhir_response(
exception.error.value.get("fhir_coding")
),
methods="DELETE",
).create_api_gateway_response()
217 changes: 217 additions & 0 deletions lambdas/services/delete_fhir_document_reference_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import uuid
from datetime import datetime, timezone
from typing import Dict, Optional

from botocore.exceptions import ClientError
from enums.document_retention import DocumentRetentionDays
from enums.lambda_error import LambdaError
from enums.metadata_field_names import DocumentReferenceMetadataFields
from enums.mtls import MtlsCommonNames
from enums.snomed_codes import SnomedCode, SnomedCodes
from enums.supported_document_types import SupportedDocumentTypes
from models.document_reference import DocumentReference
from pydantic import ValidationError
from services.document_deletion_service import DocumentDeletionService
from services.document_service import DocumentService
from services.fhir_document_reference_service_base import (
FhirDocumentReferenceServiceBase,
)
from utils.audit_logging_setup import LoggingService
from utils.common_query_filters import NotDeleted
from utils.exceptions import DynamoServiceException, InvalidNhsNumberException
from utils.lambda_exceptions import (
DocumentDeletionServiceException,
DocumentRefException,
)
from utils.lambda_header_utils import validate_common_name_in_mtls
from utils.request_context import request_context
from utils.utilities import validate_nhs_number

PARAM_SUBJECT_IDENTIFIER = "subject:identifier"
DOCUMENT_REFERENCE_IDENTIFIER = "_id"

logger = LoggingService(__name__)


class DeleteFhirDocumentReferenceService(FhirDocumentReferenceServiceBase):
def __init__(self):
super().__init__()

def process_fhir_document_reference(
self, event: dict = {}
) -> list[DocumentReference]:
"""
Process a FHIR Document Reference DELETE request

Returns:
FHIR Document Reference response
"""
try:
common_name = validate_common_name_in_mtls(event.get("requestContext", {}))
deletion_identifiers = self.extract_parameters(event=event)
if any(v is None for v in deletion_identifiers):
logger.error("FHIR document validation error: NhsNumber/id")
raise DocumentRefException(
400, LambdaError.DocumentReferenceMissingParameters
)

if len(deletion_identifiers) < 2:
return []

if not self.is_uuid(deletion_identifiers[0]):
logger.error("FHIR document validation error: Id")
raise DocumentRefException(
400, LambdaError.DocumentReferenceMissingParameters
)

doc_type = self._determine_document_type_based_on_common_name(common_name)

if not validate_nhs_number(deletion_identifiers[1]):
logger.error("FHIR document validation error: NhsNumber")
raise DocumentRefException(
400, LambdaError.DocumentReferenceMissingParameters
)

request_context.patient_nhs_no = deletion_identifiers[1]
if doc_type.code != SnomedCodes.PATIENT_DATA.value.code:
deletion_service = DocumentDeletionService()

document_types = [SupportedDocumentTypes.LG]
files_deleted = deletion_service.handle_reference_delete(
deletion_identifiers[1],
document_types,
document_id=deletion_identifiers[0],
fhir=True,
)
else:
files_deleted = (
self.delete_fhir_document_reference_by_nhs_id_and_doc_id(
nhs_number=deletion_identifiers[1],
doc_id=deletion_identifiers[0],
doc_type=doc_type,
)
)
return files_deleted

except (ValidationError, InvalidNhsNumberException) as e:
logger.error(f"FHIR document validation error: {str(e)}")
raise DocumentRefException(400, LambdaError.DocRefNoParse)
except ClientError as e:
logger.error(f"AWS client error: {str(e)}")
raise DocumentRefException(500, LambdaError.InternalServerError)

def delete_fhir_document_reference_by_nhs_id_and_doc_id(
self, nhs_number: str, doc_id: str, doc_type: SnomedCode
) -> DocumentReference | None:
dynamo_table = self._get_dynamo_table_for_doc_type(doc_type)
document_service = DocumentService()
document = document_service.get_item_agnostic(
partion_key={DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number},
sort_key={DocumentReferenceMetadataFields.ID.value: doc_id},
table_name=dynamo_table,
)
if not document:
return None
try:
document_service.delete_document_reference(
table_name=dynamo_table,
document_reference=document,
document_ttl_days=DocumentRetentionDays.SOFT_DELETE,
key_pair={
DocumentReferenceMetadataFields.ID.value: doc_id,
DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number,
},
)
logger.info(
f"Deleted document of type {doc_type.display_name}",
{"Result": "Successful deletion"},
)
return document
except (ClientError, DynamoServiceException) as e:
logger.error(
f"{LambdaError.DocDelClient.to_str()}: {str(e)}",
{"Results": "Failed to delete documents"},
)
raise DocumentDeletionServiceException(500, LambdaError.DocDelClient)

def delete_fhir_document_references_by_nhs_id(
self, nhs_number: str, doc_type: SnomedCode
) -> list[DocumentReference] | None:
dynamo_table = self._get_dynamo_table_for_doc_type(doc_type)
document_service = DocumentService()
documents = document_service.fetch_documents_from_table(
search_condition=nhs_number,
search_key="NhsNumber",
table_name=dynamo_table,
query_filter=NotDeleted,
)
if not documents:
return None
try:
document_service.delete_document_references(
table_name=dynamo_table,
document_references=documents,
document_ttl_days=DocumentRetentionDays.SOFT_DELETE,
)
logger.info(
f"Deleted document of type {doc_type.display_name}",
{"Result": "Successful deletion"},
)
return documents
except (ClientError, DynamoServiceException) as e:
logger.error(
f"{LambdaError.DocDelClient.to_str()}: {str(e)}",
{"Results": "Failed to delete documents"},
)
raise DocumentDeletionServiceException(500, LambdaError.DocDelClient)

def _determine_document_type_based_on_common_name(
self, common_name: MtlsCommonNames | None
) -> SnomedCode:
if not common_name:
"""Determine the document type based on common_name"""
return SnomedCodes.LLOYD_GEORGE.value
if common_name not in MtlsCommonNames:
logger.error(f"mTLS common name {common_name} - is not supported")
raise DocumentRefException(400, LambdaError.DocRefInvalidType)

return SnomedCodes.PATIENT_DATA.value

def is_uuid(self, value: str) -> bool:
try:
uuid.UUID(value)
return True
except (ValueError, TypeError):
return False

def extract_parameters(self, event) -> list[str]:
nhs_id, document_reference_id = self.extract_document_query_parameters(
event.get("queryStringParameters") or {}
)

if not nhs_id or not document_reference_id:
logger.error("FHIR document validation error: Missing query parameters.")
raise DocumentRefException(400, LambdaError.DocRefNoParse)

return [document_reference_id, nhs_id]

def extract_document_path_parameters(self, event):
"""Extract DocumentReference ID from path parameters"""
doc_ref_id = (event.get("pathParameters") or {}).get("id")
return doc_ref_id

def extract_document_query_parameters(
self, query_string: Dict[str, str]
) -> tuple[Optional[str], Optional[str]]:
nhs_number = None
document_reference_id = None

for key, value in query_string.items():
if key == PARAM_SUBJECT_IDENTIFIER:
nhs_number = value.split("|")[-1]
elif key == DOCUMENT_REFERENCE_IDENTIFIER:
document_reference_id = value
else:
logger.warning(f"Unknown query parameter: {key}")

return nhs_number, document_reference_id
11 changes: 9 additions & 2 deletions lambdas/services/document_deletion_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,21 @@ def __init__(self):
self.document_service = DocumentService()
self.stitch_service = LloydGeorgeStitchJobService()
self.sqs_service = SQSService()
self.fhir = False

def handle_reference_delete(
self,
nhs_number: str,
doc_types: list[SupportedDocumentTypes],
document_id: str | None = None,
fhir: bool = False,
) -> list[DocumentReference]:
if document_id:
self.delete_document_by_id(nhs_number, document_id)
return [document_id]
self.fhir = fhir
result = self.delete_document_by_id(nhs_number, document_id)
if result != []:
return [document_id]
return result
else:
return self.delete_documents_by_types(nhs_number, doc_types)

Expand All @@ -54,6 +59,8 @@ def delete_document_by_id(self, nhs_number: str, document_id: str):
)

if len(doc_ref_list) == 0:
if self.fhir:
return []
raise DocumentDeletionServiceException(404, LambdaError.DocDelNull)

document_ref: DocumentReference = doc_ref_list[0]
Expand Down
Loading
Loading