From 94a4a5017a2cda66761e3630e750fabe1a8d33c9 Mon Sep 17 00:00:00 2001 From: suejinkim20 Date: Fri, 31 Jul 2026 14:09:29 -0400 Subject: [PATCH 1/5] add buildContactPayload utility function and test --- .../freshdesk/buildContactPayload.test.ts | 45 +++++++++++++++++++ .../src/util/freshdesk/buildContactPayload.ts | 18 ++++++++ apps/site/src/util/freshdesk/index.ts | 1 + 3 files changed, 64 insertions(+) create mode 100644 apps/site/src/util/freshdesk/buildContactPayload.test.ts create mode 100644 apps/site/src/util/freshdesk/buildContactPayload.ts diff --git a/apps/site/src/util/freshdesk/buildContactPayload.test.ts b/apps/site/src/util/freshdesk/buildContactPayload.test.ts new file mode 100644 index 00000000..5f64eafa --- /dev/null +++ b/apps/site/src/util/freshdesk/buildContactPayload.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { buildContactPayload } from './buildContactPayload'; + +describe('buildContactPayload', () => { + it('returns email only when name is not provided', () => { + const payload = buildContactPayload({ email: 'jane@university.edu' }); + expect(payload).toEqual({ email: 'jane@university.edu' }); + expect(payload.name).toBeUndefined(); + }); + + it('includes name when provided', () => { + const payload = buildContactPayload({ + email: 'jane@university.edu', + name: 'Jane Researcher', + }); + expect(payload).toEqual({ + email: 'jane@university.edu', + name: 'Jane Researcher', + }); + }); + + it('trims whitespace from name', () => { + const payload = buildContactPayload({ + email: 'jane@university.edu', + name: ' Jane Researcher ', + }); + expect(payload.name).toBe('Jane Researcher'); + }); + + it('omits name when it is an empty string', () => { + const payload = buildContactPayload({ + email: 'jane@university.edu', + name: '', + }); + expect(payload.name).toBeUndefined(); + }); + + it('omits name when it is only whitespace', () => { + const payload = buildContactPayload({ + email: 'jane@university.edu', + name: ' ', + }); + expect(payload.name).toBeUndefined(); + }); +}); diff --git a/apps/site/src/util/freshdesk/buildContactPayload.ts b/apps/site/src/util/freshdesk/buildContactPayload.ts new file mode 100644 index 00000000..5f6f1886 --- /dev/null +++ b/apps/site/src/util/freshdesk/buildContactPayload.ts @@ -0,0 +1,18 @@ +export interface ContactPayload { + name?: string; + email: string; +} + +export function buildContactPayload( + values: Record, +): ContactPayload { + const payload: ContactPayload = { + email: values.email as string, + }; + + if (values.name && typeof values.name === 'string' && values.name.trim()) { + payload.name = values.name.trim(); + } + + return payload; +} diff --git a/apps/site/src/util/freshdesk/index.ts b/apps/site/src/util/freshdesk/index.ts index f38547a8..4eecee85 100644 --- a/apps/site/src/util/freshdesk/index.ts +++ b/apps/site/src/util/freshdesk/index.ts @@ -1,3 +1,4 @@ +export * from './buildContactPayload.ts'; export * from './buildCustomObjectPayload.ts'; export * from './buildPayload'; export * from './getCustomObjectRecords'; From b5f0cc89176a31a1452920412339a955385d2922 Mon Sep 17 00:00:00 2001 From: suejinkim20 Date: Fri, 31 Jul 2026 14:09:47 -0400 Subject: [PATCH 2/5] implement function to handle previously created freshdesk contacts --- services/freshdesk/handler.py | 134 +++++++++++++++++++++++++++++----- 1 file changed, 115 insertions(+), 19 deletions(-) diff --git a/services/freshdesk/handler.py b/services/freshdesk/handler.py index 5a534322..218cb287 100644 --- a/services/freshdesk/handler.py +++ b/services/freshdesk/handler.py @@ -37,6 +37,7 @@ def cors_headers(event): 'https://biodatacatalyst.nhlbi.nih.gov', 'https://staging.biodatacatalyst.nhlbi.nih.gov', 'http://localhost:8000', + 'http://localhost:4321', ] headers = { @@ -50,6 +51,78 @@ def cors_headers(event): return headers +def _upsert_contact(payload, auth, base_url, headers): + """ + Create or update a Freshdesk contact based on email address. + + Why upsert? + Submitting any form (tickets, custom objects) automatically creates a + Freshdesk contact for the submitter's email. If a user later tries to + join and their email already exists as a contact, a straight POST would + fail with a 409 conflict. The upsert pattern handles both cases cleanly: + new users get created, returning users get updated without error. + + Flow: + 1. Search for an existing contact by email + 2. If found — update the existing contact with PUT + 3. If not found — create a new contact with POST + + Args: + payload (dict): Contact data from the form. Must include `email`. + auth (str): base64-encoded Basic Auth header value. + base_url (str): Freshdesk API base URL (e.g. https://org.freshdesk.com/api/v2). + headers (dict): Response headers to return to the caller. + + Returns: + dict: Lambda proxy integration response. + """ + email = payload.get('email') + if not email: + return _error(400, 'Missing email in contact payload', headers) + + print(f'Upserting contact for email: {email}') + + # Step 1 — Search for existing contact by email. + # Freshdesk returns a list — we take the first match if any exist. + search_url = f'{base_url}/contacts?email={urllib.parse.quote(email)}' + search_req = urllib.request.Request(search_url, method='GET') + search_req.add_header('Authorization', f'Basic {auth}') + search_req.add_header('Content-Type', 'application/json') + + try: + with urllib.request.urlopen(search_req) as res: + contacts = json.loads(res.read().decode()) + except urllib.error.HTTPError as e: + error = e.read().decode() + print(f'Error searching for contact: {error}') + return { + 'statusCode': e.code, + 'headers': headers, + 'body': json.dumps({'error': e.reason}) + } + except Exception as e: + print(f'Unexpected error searching for contact: {e}') + return { + 'statusCode': 500, + 'headers': headers, + 'body': json.dumps({'error': str(e)}) + } + + body = json.dumps(payload).encode('utf-8') + + if contacts: + # Step 2 — Contact exists. Update with PUT. + contact_id = contacts[0].get('id') + print(f'Contact found (id: {contact_id}), updating...') + update_url = f'{base_url}/contacts/{contact_id}' + return _proxy_request(update_url, 'PUT', body, auth, headers) + else: + # Step 3 — Contact does not exist. Create with POST. + print('No existing contact found, creating new contact...') + create_url = f'{base_url}/contacts' + return _proxy_request(create_url, 'POST', body, auth, headers) + + def lambda_handler(event, context): """ AWS Lambda function handler. think: router. @@ -105,15 +178,6 @@ def lambda_handler(event, context): # POST routes (/cloud-credits, /join) if method == 'POST': - route_map = { - 'join': 'contacts', - 'cloud-credits': 'tickets' - } - # ensure target resource exists - resource = route_map.get(path) - if not resource: - return _error(404, f'Unknown POST route: /{path}', headers) - # ensure body exists body = event.get('body') if not body: @@ -137,23 +201,48 @@ def lambda_handler(event, context): # remove token before forwarding payload.pop('recaptcha_token', None) - body = json.dumps(payload) - - url = f'{base_url}/{resource}' - return _proxy_request(url, 'POST', body, auth, headers) + # honeypot check — silently discard bot submissions. + # real users never see or fill this field. + # the bot sees a success response and doesn't know it was caught. + if payload.pop('website', ''): + print('Honeypot field populated — discarding submission silently') + return { + 'statusCode': 200, + 'headers': headers, + 'body': json.dumps({'message': 'ok'}) + } + + # /join — upsert contact (check by email, update or create) + # handled separately from the generic route_map because it requires + # a search-then-write flow rather than a direct POST. + if path == 'join': + return _upsert_contact(payload, auth, base_url, headers) + + # generic POST routes — direct proxy to Freshdesk + route_map = { + 'cloud-credits': 'tickets', + 'published-research': 'tickets', + } + + resource = route_map.get(path) + if not resource: + return _error(404, f'Unknown POST route: /{path}', headers) + + url = f'{base_url}/{resource}' + return _proxy_request(url, 'POST', json.dumps(payload).encode('utf-8'), auth, headers) return _error(405, f'Method {method} not allowed for /{path}', headers) def _proxy_request(url, method, body, auth, headers): """ - send proxied HTTP request to Freshdesk with - the given method, URL, and payload. + Send a proxied HTTP request to Freshdesk with the given method, URL, + and payload. Args: url (str): Freshdesk API URL - method (str): HTTP method (GET, POST) - body (str): request body (JSON string) + method (str): HTTP method (GET, POST, PUT) + body (bytes): request body (encoded JSON bytes) or None for GET auth (str): base64-encoded Basic Auth header headers (dict): response headers to return to the caller @@ -166,8 +255,6 @@ def _proxy_request(url, method, body, auth, headers): req.add_header('Content-Type', 'application/json') try: - if body: - body = body.encode('utf-8') with urllib.request.urlopen(req, data=body) as res: response_body = res.read().decode() return { @@ -178,12 +265,21 @@ def _proxy_request(url, method, body, auth, headers): except urllib.error.HTTPError as e: error = e.read().decode() print('Freshdesk error response:', error) + print('Freshdesk error code:', e.code) + + if e.code == 409: + return { + 'statusCode': 409, + 'headers': headers, + 'body': json.dumps({'error': 'already_exists'}) + } return { 'statusCode': e.code, 'headers': headers, 'body': json.dumps({ 'error': e.reason }) } + except Exception as e: return { 'statusCode': 500, From 7252978664d937258a8c9c0758febb84f75742a3 Mon Sep 17 00:00:00 2001 From: suejinkim20 Date: Fri, 31 Jul 2026 14:10:10 -0400 Subject: [PATCH 3/5] add FreshdeskContactForm component and integrate with join page --- .../components/forms/FreshdeskContactForm.tsx | 206 ++++++++++++++++++ .../components/forms/util/errorMessages.ts | 5 + apps/site/src/pages/join.astro | 28 +++ 3 files changed, 239 insertions(+) create mode 100644 apps/site/src/components/forms/FreshdeskContactForm.tsx create mode 100644 apps/site/src/pages/join.astro diff --git a/apps/site/src/components/forms/FreshdeskContactForm.tsx b/apps/site/src/components/forms/FreshdeskContactForm.tsx new file mode 100644 index 00000000..387b5b81 --- /dev/null +++ b/apps/site/src/components/forms/FreshdeskContactForm.tsx @@ -0,0 +1,206 @@ +import { useRef, useState } from 'react'; +import type { FieldError } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; +import { buildContactPayload } from '../../util/freshdesk/buildContactPayload'; +import { getRecaptchaToken } from '../../util/recaptcha'; +import HoneypotField from './HoneypotField'; +import { formErrors, formStatus } from './util/errorMessages'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type FormStatus = + | 'idle' + | 'submitting' + | 'success' + | 'already_exists' + | 'error'; + +interface FreshdeskContactFormProps { + // The Lambda proxy endpoint URL. + // Set via FRESHDESK_PROXY_URL in apps/site/.env. + submitUrl: string; + // The reCAPTCHA v3 site key for the current environment. + // Passed from the Astro page via import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY. + recaptchaSiteKey: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function FreshdeskContactForm({ + submitUrl, + recaptchaSiteKey, +}: FreshdeskContactFormProps) { + const [status, setStatus] = useState('idle'); + const [submitError, setSubmitError] = useState(null); + const confirmationRef = useRef(null); + + const methods = useForm>({ + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + + const { + register, + handleSubmit, + formState: { errors }, + } = methods; + + // --------------------------------------------------------------------------- + // Submit handler + // --------------------------------------------------------------------------- + + const onSubmit = async (values: Record) => { + setStatus('submitting'); + setSubmitError(null); + + try { + const recaptchaToken = await getRecaptchaToken(recaptchaSiteKey); + + const payload = { + ...buildContactPayload(values), + recaptcha_token: recaptchaToken, + }; + + const response = await fetch(`${submitUrl}/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + // 409 means the email already exists as a Freshdesk contact. + // Show a friendly message rather than treating it as an error. + if (response.status === 409) { + setStatus('already_exists'); + setTimeout(() => confirmationRef.current?.focus(), 0); + return; + } + + if (!response.ok) { + throw new Error(`Submit failed: ${response.status}`); + } + + setStatus('success'); + setTimeout(() => confirmationRef.current?.focus(), 0); + } catch { + setStatus('error'); + setSubmitError(formErrors.submission.general); + } + }; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + const isComplete = status === 'success' || status === 'already_exists'; + + return ( + +
+ {/* Success / already exists message — shown in the same location + as the form so the page layout doesn't shift on submission. */} + {isComplete && ( +
+
+
+

+ {status === 'already_exists' + ? formStatus.alreadySubscribed + : "You're subscribed! Check your inbox for a confirmation."} +

+
+
+
+ )} + + {/* Form — hidden after successful submission */} + {!isComplete && ( +
+ {/* Submission error banner */} + {status === 'error' && submitError && ( +
+
+

{submitError}

+
+
+ )} + +
+ {/* Name field — optional */} +
+ + +
+ + {/* Email field — required */} +
+ + {errors.email && ( + + {(errors.email as FieldError).message} + + )} + +
+
+ + + + + + )} +
+
+ ); +} diff --git a/apps/site/src/components/forms/util/errorMessages.ts b/apps/site/src/components/forms/util/errorMessages.ts index e00f753c..845b3471 100644 --- a/apps/site/src/components/forms/util/errorMessages.ts +++ b/apps/site/src/components/forms/util/errorMessages.ts @@ -147,6 +147,11 @@ export const formStatus = { 'Try again later, or contact us by email at biodatacatalyst@nhlbi.nih.gov if you need help right away.', unavailableHeading: "This form isn't available right now.", + // Shown when a contact with this email already exists in Freshdesk. + // Used by FreshdeskContactForm to acknowledge returning users. + alreadySubscribed: + 'Your previous join request is still in process; email biodatacatalyst@nhlbi.nih.gov to have an activation email resent.', + // Default success heading — per-form follow-up copy is defined per form successHeading: 'Submission Received', successText: diff --git a/apps/site/src/pages/join.astro b/apps/site/src/pages/join.astro new file mode 100644 index 00000000..a0738520 --- /dev/null +++ b/apps/site/src/pages/join.astro @@ -0,0 +1,28 @@ +--- +import FreshdeskContactForm from '@components/forms/FreshdeskContactForm.tsx'; +import Base from '@layouts/Base.astro'; +--- + + +
+
+
+
+ +

Join the Community

+ +

+ Sign up to receive updates and stay connected with the community. +

+ + + +
+
+
+
+ From 24867d5a6713de93664abeacd0e601ec2dcb0e1c Mon Sep 17 00:00:00 2001 From: suejinkim20 Date: Tue, 4 Aug 2026 07:39:57 -0400 Subject: [PATCH 4/5] separate create freshdesk contact logic into join_handler.py --- services/freshdesk/handler.py | 88 +--------------------- services/freshdesk/join_handler.py | 117 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 84 deletions(-) create mode 100644 services/freshdesk/join_handler.py diff --git a/services/freshdesk/handler.py b/services/freshdesk/handler.py index 218cb287..be1b34b9 100644 --- a/services/freshdesk/handler.py +++ b/services/freshdesk/handler.py @@ -3,9 +3,9 @@ import base64 import urllib.request import urllib.error - import urllib.parse -import urllib.request + +from join_handler import handle_join def verify_recaptcha(token): print("---- reCAPTCHA verification started ----") @@ -51,78 +51,6 @@ def cors_headers(event): return headers -def _upsert_contact(payload, auth, base_url, headers): - """ - Create or update a Freshdesk contact based on email address. - - Why upsert? - Submitting any form (tickets, custom objects) automatically creates a - Freshdesk contact for the submitter's email. If a user later tries to - join and their email already exists as a contact, a straight POST would - fail with a 409 conflict. The upsert pattern handles both cases cleanly: - new users get created, returning users get updated without error. - - Flow: - 1. Search for an existing contact by email - 2. If found — update the existing contact with PUT - 3. If not found — create a new contact with POST - - Args: - payload (dict): Contact data from the form. Must include `email`. - auth (str): base64-encoded Basic Auth header value. - base_url (str): Freshdesk API base URL (e.g. https://org.freshdesk.com/api/v2). - headers (dict): Response headers to return to the caller. - - Returns: - dict: Lambda proxy integration response. - """ - email = payload.get('email') - if not email: - return _error(400, 'Missing email in contact payload', headers) - - print(f'Upserting contact for email: {email}') - - # Step 1 — Search for existing contact by email. - # Freshdesk returns a list — we take the first match if any exist. - search_url = f'{base_url}/contacts?email={urllib.parse.quote(email)}' - search_req = urllib.request.Request(search_url, method='GET') - search_req.add_header('Authorization', f'Basic {auth}') - search_req.add_header('Content-Type', 'application/json') - - try: - with urllib.request.urlopen(search_req) as res: - contacts = json.loads(res.read().decode()) - except urllib.error.HTTPError as e: - error = e.read().decode() - print(f'Error searching for contact: {error}') - return { - 'statusCode': e.code, - 'headers': headers, - 'body': json.dumps({'error': e.reason}) - } - except Exception as e: - print(f'Unexpected error searching for contact: {e}') - return { - 'statusCode': 500, - 'headers': headers, - 'body': json.dumps({'error': str(e)}) - } - - body = json.dumps(payload).encode('utf-8') - - if contacts: - # Step 2 — Contact exists. Update with PUT. - contact_id = contacts[0].get('id') - print(f'Contact found (id: {contact_id}), updating...') - update_url = f'{base_url}/contacts/{contact_id}' - return _proxy_request(update_url, 'PUT', body, auth, headers) - else: - # Step 3 — Contact does not exist. Create with POST. - print('No existing contact found, creating new contact...') - create_url = f'{base_url}/contacts' - return _proxy_request(create_url, 'POST', body, auth, headers) - - def lambda_handler(event, context): """ AWS Lambda function handler. think: router. @@ -176,7 +104,7 @@ def lambda_handler(event, context): print('No route match for path:', normalized_path) return _error(404, 'Not Found', headers) - # POST routes (/cloud-credits, /join) + # POST routes if method == 'POST': # ensure body exists body = event.get('body') @@ -217,7 +145,7 @@ def lambda_handler(event, context): # handled separately from the generic route_map because it requires # a search-then-write flow rather than a direct POST. if path == 'join': - return _upsert_contact(payload, auth, base_url, headers) + return handle_join(payload, auth, base_url, headers, _proxy_request) # generic POST routes — direct proxy to Freshdesk route_map = { @@ -265,14 +193,6 @@ def _proxy_request(url, method, body, auth, headers): except urllib.error.HTTPError as e: error = e.read().decode() print('Freshdesk error response:', error) - print('Freshdesk error code:', e.code) - - if e.code == 409: - return { - 'statusCode': 409, - 'headers': headers, - 'body': json.dumps({'error': 'already_exists'}) - } return { 'statusCode': e.code, diff --git a/services/freshdesk/join_handler.py b/services/freshdesk/join_handler.py new file mode 100644 index 00000000..ff7a7678 --- /dev/null +++ b/services/freshdesk/join_handler.py @@ -0,0 +1,117 @@ +""" +join_handler.py + +Handles the contact upsert flow for the /join route. + +Called by handler.py after security checks (CORS, reCAPTCHA, honeypot) +have already passed. This module is responsible for one thing: +determining whether to create or update a Freshdesk contact based on +whether the submitted email already exists. + +Why separate from handler.py? + handler.py is a security proxy — its job is CORS, reCAPTCHA, honeypot, + and forwarding. The upsert logic is business logic that doesn't belong + there. Keeping it here makes both files easier to reason about and test. + +Flow: + 1. Search for existing contact by email + GET /api/v2/contacts?email={email} + 2. If found — update the existing contact + PUT /api/v2/contacts/{id} + 3. If not found — create a new contact + POST /api/v2/contacts + +Error tagging: + All print statements are prefixed with [JOIN] so they're immediately + identifiable in CloudWatch logs without searching through combined + proxy logs. +""" + +import json +import urllib.error +import urllib.parse +import urllib.request + + +def handle_join(payload, auth, base_url, headers, proxy_request): + """ + Create or update a Freshdesk contact based on email address. + + Args: + payload (dict): Contact data from the form. Must include `email`. + Already stripped of recaptcha_token and honeypot by handler.py. + auth (str): base64-encoded Basic Auth header value. + base_url (str): Freshdesk API base URL + (e.g. https://org.freshdesk.com/api/v2). + headers (dict): Response headers to return to the caller. + proxy_request (callable): The _proxy_request function from handler.py. + Passed in so this module doesn't duplicate HTTP request logic. + + Returns: + dict: Lambda proxy integration response. + """ + email = payload.get('email') + if not email: + print('[JOIN] ERROR: Missing email in payload') + return { + 'statusCode': 400, + 'headers': headers, + 'body': json.dumps({'error': 'Missing email'}), + } + + print(f'[JOIN] Processing contact for email: {email}') + + # Step 1 — Search for existing contact by email. + # quote(email, safe='') ensures + signs in email addresses are encoded + # as %2B rather than left as-is, which would be misinterpreted by + # Freshdesk's query parser. + search_url = f'{base_url}/contacts?email={urllib.parse.quote(email, safe="")}' + search_req = urllib.request.Request(search_url, method='GET') + search_req.add_header('Authorization', f'Basic {auth}') + search_req.add_header('Content-Type', 'application/json') + + try: + with urllib.request.urlopen(search_req) as res: + contacts = json.loads(res.read().decode()) + except urllib.error.HTTPError as e: + error = e.read().decode() + print(f'[JOIN] ERROR: Contact search failed ({e.code}): {error}') + return { + 'statusCode': e.code, + 'headers': headers, + 'body': json.dumps({'error': e.reason}), + } + except Exception as e: + print(f'[JOIN] ERROR: Unexpected error during contact search: {e}') + return { + 'statusCode': 500, + 'headers': headers, + 'body': json.dumps({'error': str(e)}), + } + + body = json.dumps(payload).encode('utf-8') + + if contacts: + # Step 2 — Contact exists. Update with PUT. + contact_id = contacts[0].get('id') + print(f'[JOIN] Contact found (id: {contact_id}), updating...') + update_url = f'{base_url}/contacts/{contact_id}' + return proxy_request(update_url, 'PUT', body, auth, headers) + else: + # Step 3 — Contact does not exist. Create with POST. + print('[JOIN] No existing contact found, creating...') + create_url = f'{base_url}/contacts' + result = proxy_request(create_url, 'POST', body, auth, headers) + + # 409 means a contact with this email already exists — possible if + # the email lookup missed it (e.g. race condition or Freshdesk lag). + # Return a specific error code so FreshdeskContactForm can show + # a friendly "already signed up" message rather than a generic error. + if result.get('statusCode') == 409: + return { + 'statusCode': 409, + 'headers': headers, + 'body': json.dumps({'error': 'already_exists'}), + } + + return result \ No newline at end of file From f06b3280a87a0ea93025bff0193e320889a01a9e Mon Sep 17 00:00:00 2001 From: suejinkim20 Date: Tue, 4 Aug 2026 08:10:26 -0400 Subject: [PATCH 5/5] use client:idle for FreshdeskContactForm on join page --- apps/site/src/pages/join.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/site/src/pages/join.astro b/apps/site/src/pages/join.astro index a0738520..137cb277 100644 --- a/apps/site/src/pages/join.astro +++ b/apps/site/src/pages/join.astro @@ -18,7 +18,7 @@ import Base from '@layouts/Base.astro';