From 7d2c938e6f38fa82c56927ba455951e303bfa184 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 2 Mar 2026 18:32:58 +0800 Subject: [PATCH] fix: normalize docuseal completed_at to utc string contract --- README.md | 2 + apps/worker/README.md | 22 +++++++++ apps/worker/src/five08/backend/api.py | 17 ++++++- .../five08/worker/crm/docuseal_processor.py | 38 ++++++++++++++-- apps/worker/src/five08/worker/jobs.py | 10 ++++- tests/unit/test_backend_api.py | 39 ++++++++++++++++ tests/unit/test_docuseal_processor.py | 45 ++++++++++++++++++- 7 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 apps/worker/README.md diff --git a/README.md b/README.md index 5eecc513..9aa7e0bc 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Migrations: - `POST /webhooks/{source}`: Generic webhook enqueue endpoint. - `POST /webhooks/espocrm`: EspoCRM webhook endpoint (expects array payload). - `POST /webhooks/espocrm/people-sync`: EspoCRM contact-change webhook for people cache sync. +- `POST /webhooks/docuseal`: See worker webhook contract docs in + [`apps/worker/README.md`](apps/worker/README.md#webhooks). - `POST /process-contact/{contact_id}`: Manually enqueue one contact skills job. - `POST /sync/people`: Manually enqueue a full CRM->people cache sync. - `POST /audit/events`: Persist one human audit event (`discord` or `admin_dashboard`). diff --git a/apps/worker/README.md b/apps/worker/README.md new file mode 100644 index 00000000..ac331377 --- /dev/null +++ b/apps/worker/README.md @@ -0,0 +1,22 @@ +# Worker Service + +## Webhooks + +### `POST /webhooks/docuseal` + +Enqueues DocuSeal agreement-signing jobs. + +- Job input contract for queueing: `completed_at` is a UTC string using `YYYY-MM-DD HH:mm:ss`. +- Example value: `2026-03-02 10:02:30`. + +### `POST /webhooks/{source}` + +Generic webhook enqueue endpoint. + +### `POST /webhooks/espocrm` + +EspoCRM webhook endpoint (expects array payload). + +### `POST /webhooks/espocrm/people-sync` + +EspoCRM contact-change webhook for people cache sync. diff --git a/apps/worker/src/five08/backend/api.py b/apps/worker/src/five08/backend/api.py index 8acc721e..af045b8d 100644 --- a/apps/worker/src/five08/backend/api.py +++ b/apps/worker/src/five08/backend/api.py @@ -124,6 +124,15 @@ def _resume_extract_model_name() -> str: return "heuristic" +def _coerce_docuseal_completed_at_to_utc(value: str) -> str: + """Normalize Docuseal completion timestamps for queue/job payload contract.""" + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + utc_value = parsed.astimezone(timezone.utc) + return utc_value.strftime("%Y-%m-%d %H:%M:%S") + + def _crm_sync_idempotency_key(*, now: datetime) -> str: interval_seconds = max(1, settings.crm_sync_interval_seconds) bucket = int(now.timestamp()) // interval_seconds @@ -692,7 +701,11 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse: async def docuseal_webhook_handler(request: Request) -> JSONResponse: - """Process a Docuseal form.completed webhook and enqueue agreement job.""" + """Process a Docuseal form.completed webhook and enqueue agreement job. + + Job payload contract for the queue is: + completed_at = "YYYY-MM-DD HH:mm:ss" in UTC. + """ if not _is_authorized(request): return JSONResponse({"error": "unauthorized"}, status_code=401) @@ -764,7 +777,7 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse: return JSONResponse({"error": "invalid_payload"}, status_code=400) try: - datetime.fromisoformat(completed_at.replace("Z", "+00:00")) + completed_at = _coerce_docuseal_completed_at_to_utc(completed_at) except ValueError: return JSONResponse({"error": "invalid_payload"}, status_code=400) diff --git a/apps/worker/src/five08/worker/crm/docuseal_processor.py b/apps/worker/src/five08/worker/crm/docuseal_processor.py index 54823d1d..5b459e70 100644 --- a/apps/worker/src/five08/worker/crm/docuseal_processor.py +++ b/apps/worker/src/five08/worker/crm/docuseal_processor.py @@ -1,6 +1,7 @@ """Docuseal member agreement processing workflow.""" import logging +from datetime import datetime, timezone from typing import Any from five08.clients.espo import EspoAPI, EspoAPIError @@ -17,13 +18,27 @@ def __init__(self) -> None: api_url = settings.espo_base_url.rstrip("/") + "/api/v1" self.api = EspoAPI(api_url, settings.espo_api_key) + @staticmethod + def _normalize_completed_at(completed_at: str) -> str: + """Normalize timestamp to the CRM-expected UTC format.""" + parsed = datetime.fromisoformat(completed_at.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + else: + parsed = parsed.astimezone(timezone.utc) + return parsed.strftime("%Y-%m-%d %H:%M:%S") + def process_agreement( self, email: str, completed_at: str, submission_id: int, ) -> dict[str, Any]: - """Search for the signer by email and update cMemberAgreementSignedAt.""" + """Search for the signer by email and update cMemberAgreementSignedAt. + + Expected input is the queue contract value: + ``YYYY-MM-DD HH:mm:ss`` in UTC. + """ masked_email = mask_email(email) try: @@ -66,12 +81,29 @@ def process_agreement( contact = contacts[0] contact_id = contact["id"] + try: + crm_completed_at = self._normalize_completed_at(completed_at) + except ValueError as exc: + logger.error( + "CRM update failed for contact_id=%s due to invalid datetime=%s: %s", + contact_id, + completed_at, + exc, + ) + return { + "success": False, + "masked_email": masked_email, + "submission_id": submission_id, + "contact_id": contact_id, + "error": f"invalid_completed_at: {exc}", + } + try: self.api.request( "PUT", f"Contact/{contact_id}", { - "cMemberAgreementSignedAt": completed_at, + "cMemberAgreementSignedAt": crm_completed_at, }, ) except EspoAPIError as exc: @@ -94,5 +126,5 @@ def process_agreement( "masked_email": masked_email, "contact_id": contact_id, "submission_id": submission_id, - "completed_at": completed_at, + "completed_at": crm_completed_at, } diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index 5d4adbbb..a6d73fe2 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) +DOCUSEAL_COMPLETED_AT_UTC_FORMAT = "%Y-%m-%d %H:%M:%S" + + def process_contact_skills_job(contact_id: str) -> dict[str, Any]: """Process one EspoCRM contact and update their skills.""" logger.info("Processing queued contact skills job contact_id=%s", contact_id) @@ -75,7 +78,12 @@ def process_docuseal_agreement_job( completed_at: str, submission_id: int, ) -> dict[str, Any]: - """Mark a CRM contact as having signed the member agreement via Docuseal.""" + """Mark a CRM contact as having signed the member agreement via Docuseal. + + Job input contract: + - completed_at is a UTC string, formatted as ``YYYY-MM-DD HH:mm:ss``. + - Keep it string-based to match JSON job payload serialization constraints. + """ logger.info( "Processing Docuseal agreement job masked_email=%s submission_id=%s", mask_email(email), diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index 67b3cec8..68849040 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -631,9 +631,48 @@ def test_docuseal_webhook_enqueues_agreement_job( assert payload["submission_id"] == 4200 call_kwargs = mock_enqueue.call_args.kwargs + assert call_kwargs["args"] == ("member@508.dev", "2026-02-25 12:00:00", 4200) + assert call_kwargs["args"][1] == "2026-02-25 12:00:00" assert call_kwargs["idempotency_key"] == "docuseal-agreement:4200" +def test_docuseal_webhook_converts_completed_at_to_utc_payload_contract( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Docuseal timestamps should be serialized as UTC string contract payload args.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + payload = { + **_DOCUSEAL_PAYLOAD, + "data": { + **_DOCUSEAL_PAYLOAD["data"], + "completed_at": "2026-03-02T10:02:30.572+02:00", + }, + "timestamp": "2026-03-02T10:02:30.572+02:00", + } + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-utc") + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["job_id"] == "job-ds-utc" + assert payload["submission_id"] == 4200 + + call_kwargs = mock_enqueue.call_args.kwargs + assert call_kwargs["args"][1] == "2026-03-02 08:02:30" + + def test_docuseal_webhook_ignored_when_template_filter_unset( client: TestClient, auth_headers: dict[str, str], diff --git a/tests/unit/test_docuseal_processor.py b/tests/unit/test_docuseal_processor.py index 1fca8ccc..edff95bb 100644 --- a/tests/unit/test_docuseal_processor.py +++ b/tests/unit/test_docuseal_processor.py @@ -28,15 +28,58 @@ def test_docuseal_processor_marks_member_agreement_signed_timestamp() -> None: assert mock_api.request.call_count == 2 assert mock_api.request.call_args_list[1].args[1] == "Contact/contact-1" assert mock_api.request.call_args_list[1].args[2] == { - "cMemberAgreementSignedAt": "2026-02-25T12:00:00Z", + "cMemberAgreementSignedAt": "2026-02-25 12:00:00", } assert result["success"] is True assert result["masked_email"] == expected_masked assert result["contact_id"] == "contact-1" assert result["submission_id"] == 416 + assert result["completed_at"] == "2026-02-25 12:00:00" assert "email" not in result +def test_docuseal_processor_normalizes_completed_at_to_utc_timestamp() -> None: + """Processor should convert a UTC-offset timestamp before writing CRM.""" + mock_api = Mock() + mock_api.request.side_effect = [ + {"list": [{"id": "contact-1"}]}, + {"updated": True}, + ] + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email="member@508.dev", + completed_at="2026-03-02T10:02:30.572+02:00", + submission_id=416, + ) + + assert mock_api.request.call_args_list[1].args[2] == { + "cMemberAgreementSignedAt": "2026-03-02 08:02:30", + } + assert result["completed_at"] == "2026-03-02 08:02:30" + + +def test_docuseal_processor_returns_error_on_invalid_completed_at() -> None: + """Processor should return explicit invalid datetime errors instead of crashing.""" + mock_api = Mock() + mock_api.request.side_effect = [ + {"list": [{"id": "contact-1"}]}, + ] + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email="member@508.dev", + completed_at="not-a-date", + submission_id=416, + ) + + assert result["success"] is False + assert "invalid_completed_at" in result["error"] + assert mock_api.request.call_count == 1 + + def test_docuseal_processor_returns_contact_not_found_when_missing_contact() -> None: """Processor should return a contact-not-found error without raw email.""" mock_api = Mock()