Skip to content

Commit 80f1c8e

Browse files
authored
Address intake review follow-ups
Squash merge PR #346.
1 parent f568641 commit 80f1c8e

4 files changed

Lines changed: 109 additions & 21 deletions

File tree

apps/api/src/five08/backend/api.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -812,22 +812,24 @@ def _tally_intake_dry_run_mode(
812812
return "none"
813813

814814

815-
def _strip_url_query(value: str) -> str:
815+
def _strip_url_query_and_fragment(value: str) -> str:
816816
parsed = urlsplit(value)
817-
if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.query:
817+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
818818
return value
819-
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", parsed.fragment))
819+
if not parsed.query and not parsed.fragment:
820+
return value
821+
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
820822

821823

822-
def _sanitize_tally_raw_payload(value: Any) -> Any:
824+
def _sanitize_intake_raw_payload(value: Any) -> Any:
823825
if isinstance(value, Mapping):
824826
return {
825-
str(key): _sanitize_tally_raw_payload(item) for key, item in value.items()
827+
str(key): _sanitize_intake_raw_payload(item) for key, item in value.items()
826828
}
827829
if isinstance(value, list):
828-
return [_sanitize_tally_raw_payload(item) for item in value]
830+
return [_sanitize_intake_raw_payload(item) for item in value]
829831
if isinstance(value, str):
830-
return _strip_url_query(value)
832+
return _strip_url_query_and_fragment(value)
831833
return value
832834

833835

@@ -7701,6 +7703,7 @@ async def google_forms_intake_webhook_handler(request: Request) -> JSONResponse:
77017703
submitted_at=payload.submitted_at,
77027704
payload=normalized_payload,
77037705
)
7706+
normalized_payload["raw_payload"] = _sanitize_intake_raw_payload(payload_data)
77047707

77057708
queue = request.app.state.queue
77067709
try:
@@ -7789,8 +7792,8 @@ async def tally_intake_webhook_handler(request: Request) -> JSONResponse:
77897792
"email": email,
77907793
"first_name": first_name,
77917794
"last_name": last_name,
7792-
"raw_payload": _sanitize_tally_raw_payload(payload_data),
7793-
"raw_tally_fields": _sanitize_tally_raw_payload(raw_tally_fields),
7795+
"raw_payload": _sanitize_intake_raw_payload(payload_data),
7796+
"raw_tally_fields": _sanitize_intake_raw_payload(raw_tally_fields),
77947797
}
77957798
)
77967799

apps/worker/src/five08/worker/crm/intake_form_processor.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from dataclasses import dataclass
1515
from datetime import datetime, timezone
1616
from pathlib import Path
17-
from typing import Any
17+
from typing import Any, cast
1818
from urllib.parse import urljoin, urlsplit, urlunsplit
1919
from uuid import NAMESPACE_URL, uuid4, uuid5
2020

@@ -94,6 +94,13 @@ class IntakeResumeFile:
9494
source_url: str
9595

9696

97+
class _ResumeFileNotProvided:
98+
pass
99+
100+
101+
_RESUME_FILE_NOT_PROVIDED = _ResumeFileNotProvided()
102+
103+
97104
class IntakeFormProcessor:
98105
"""Process a Google Forms member intake submission against CRM."""
99106

@@ -464,7 +471,9 @@ def _build_intake_updates(
464471
payload: Mapping[str, Any],
465472
include_email: bool = True,
466473
include_last_name: bool = True,
467-
resume_file: IntakeResumeFile | None = None,
474+
resume_file: IntakeResumeFile | None | _ResumeFileNotProvided = (
475+
_RESUME_FILE_NOT_PROVIDED
476+
),
468477
) -> dict[str, Any]:
469478
updates: dict[str, Any] = {"firstName": first_name}
470479
if include_last_name:
@@ -698,22 +707,27 @@ def _build_resume_updates(
698707
self,
699708
payload: Mapping[str, Any],
700709
*,
701-
resume_file: IntakeResumeFile | None = None,
710+
resume_file: IntakeResumeFile | None | _ResumeFileNotProvided = (
711+
_RESUME_FILE_NOT_PROVIDED
712+
),
702713
) -> dict[str, Any]:
703-
if resume_file is None:
704-
resume_file = self._prepare_resume_file(payload)
705-
if resume_file is None:
714+
prepared_resume_file: IntakeResumeFile | None
715+
if resume_file is _RESUME_FILE_NOT_PROVIDED:
716+
prepared_resume_file = self._prepare_resume_file(payload)
717+
else:
718+
prepared_resume_file = cast(IntakeResumeFile | None, resume_file)
719+
if prepared_resume_file is None:
706720
return {}
707721

708722
try:
709723
resume_text = self.document_processor.extract_text(
710-
resume_file.content,
711-
resume_file.filename,
724+
prepared_resume_file.content,
725+
prepared_resume_file.filename,
712726
)
713727
except Exception as exc:
714728
logger.warning(
715729
"Failed to parse resume masked_url=%s error=%s",
716-
self._mask_resume_url_for_log(resume_file.source_url),
730+
self._mask_resume_url_for_log(prepared_resume_file.source_url),
717731
exc,
718732
)
719733
return {}

tests/unit/test_backend_api.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9286,6 +9286,10 @@ def test_google_forms_intake_enqueues_job(
92869286
"first_name": " Jane ",
92879287
"last_name": " Doe ",
92889288
"form_id": "form-1",
9289+
"resume_url": (
9290+
"https://drive.google.com/resume.pdf?signature=secret"
9291+
"#token=fragment-secret"
9292+
),
92899293
},
92909294
headers=auth_headers,
92919295
)
@@ -9302,6 +9306,17 @@ def test_google_forms_intake_enqueues_job(
93029306
assert call_kwargs["args"][0]["email"] == "member@example.com"
93039307
assert call_kwargs["args"][0]["first_name"] == "Jane"
93049308
assert call_kwargs["args"][0]["last_name"] == "Doe"
9309+
assert call_kwargs["args"][0]["resume_url"] == (
9310+
"https://drive.google.com/resume.pdf?signature=secret#token=fragment-secret"
9311+
)
9312+
assert call_kwargs["args"][0]["raw_payload"] == {
9313+
**_GOOGLE_FORMS_INTAKE_PAYLOAD,
9314+
"email": " member@example.com ",
9315+
"first_name": " Jane ",
9316+
"last_name": " Doe ",
9317+
"form_id": "form-1",
9318+
"resume_url": "https://drive.google.com/resume.pdf",
9319+
}
93059320

93069321

93079322
def test_google_forms_intake_rejects_unapproved_form_id(
@@ -9637,15 +9652,16 @@ def test_tally_intake_strips_signed_urls_from_raw_payload_before_enqueue(
96379652
"""Queued raw Tally payloads should not retain signed URL query tokens."""
96389653
tally_payload = json.loads(json.dumps(_TALLY_INTAKE_PAYLOAD))
96399654
tally_payload["data"]["submissionPdfUrl"] = (
9640-
"https://tally.so/r/abc.pdf?accessToken=secret&signature=sig"
9655+
"https://tally.so/r/abc.pdf?accessToken=secret&signature=sig#token=frag"
96419656
)
96429657
tally_payload["data"]["submissionPreviewUrl"] = (
9643-
"https://tally.so/r/abc?accessToken=secret&signature=sig"
9658+
"https://tally.so/r/abc?accessToken=secret&signature=sig#token=frag"
96449659
)
96459660
for field in tally_payload["data"]["fields"]:
96469661
if field["key"] == "question_resume":
96479662
field["value"][0]["url"] = (
96489663
"https://storage.googleapis.com/tally/resume.pdf?signature=sig"
9664+
"#token=frag"
96499665
)
96509666

96519667
with (
@@ -9664,7 +9680,7 @@ def test_tally_intake_strips_signed_urls_from_raw_payload_before_enqueue(
96649680
intake_payload = mock_enqueue.call_args.kwargs["args"][0]
96659681
assert (
96669682
intake_payload["resume_url"]
9667-
== "https://storage.googleapis.com/tally/resume.pdf?signature=sig"
9683+
== "https://storage.googleapis.com/tally/resume.pdf?signature=sig#token=frag"
96689684
)
96699685
raw_payload = intake_payload["raw_payload"]
96709686
assert raw_payload["data"]["submissionPdfUrl"] == "https://tally.so/r/abc.pdf"

tests/unit/test_intake_form_processor.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,33 @@ def test_intake_form_processor_dry_run_create_reports_resume_upload_plan() -> No
136136
mock_persist.assert_not_called()
137137

138138

139+
def test_create_prospect_does_not_retry_failed_resume_prepare() -> None:
140+
"""Create flow should not download/scan a failed resume more than once."""
141+
processor = IntakeFormProcessor()
142+
processor.api = MagicMock()
143+
processor.api.request.side_effect = [
144+
{"list": []},
145+
{"id": "contact-1"},
146+
]
147+
148+
with (
149+
patch.object(processor, "_prepare_resume_file", return_value=None) as prepare,
150+
patch.object(processor, "_persist_intake_submission"),
151+
):
152+
result = processor.process_intake(
153+
payload={
154+
"email": "new@example.com",
155+
"first_name": "New",
156+
"last_name": "Person",
157+
"resume_url": "https://tally.so/resume.pdf",
158+
"form_id": "form-1",
159+
}
160+
)
161+
162+
assert result["success"] is True
163+
prepare.assert_called_once()
164+
165+
139166
def test_intake_form_processor_dry_run_update_does_not_write_crm_or_db() -> None:
140167
"""Dry-run update should return planned updates without PUT or persistence."""
141168
processor = IntakeFormProcessor()
@@ -173,6 +200,34 @@ def test_intake_form_processor_dry_run_update_does_not_write_crm_or_db() -> None
173200
mock_persist.assert_not_called()
174201

175202

203+
def test_update_prospect_does_not_retry_failed_resume_prepare() -> None:
204+
"""Update flow should not download/scan a failed resume more than once."""
205+
processor = IntakeFormProcessor()
206+
processor.api = MagicMock()
207+
processor.api.request.side_effect = [
208+
{"list": [{"id": "contact-1", "type": "Prospect"}]},
209+
{},
210+
]
211+
212+
with (
213+
patch.object(processor, "_prepare_resume_file", return_value=None) as prepare,
214+
patch.object(processor, "_persist_intake_submission"),
215+
):
216+
result = processor.process_intake(
217+
payload={
218+
"email": "existing@example.com",
219+
"first_name": "Existing",
220+
"last_name": "Person",
221+
"github_username": "existing-dev",
222+
"resume_url": "https://tally.so/resume.pdf",
223+
"form_id": "form-1",
224+
}
225+
)
226+
227+
assert result["success"] is True
228+
prepare.assert_called_once()
229+
230+
176231
def test_intake_form_processor_uploads_resume_after_create() -> None:
177232
"""Created prospects should receive the downloaded Tally resume attachment."""
178233
processor = IntakeFormProcessor()

0 commit comments

Comments
 (0)