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..137cb277 --- /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. +

+ + + +
+
+
+
+ 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'; diff --git a/services/freshdesk/handler.py b/services/freshdesk/handler.py index 5a534322..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 ----") @@ -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 = { @@ -103,17 +104,8 @@ 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': - 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 +129,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 handle_join(payload, auth, base_url, headers, _proxy_request) + + # 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 +183,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 { @@ -184,6 +199,7 @@ def _proxy_request(url, method, body, auth, headers): 'headers': headers, 'body': json.dumps({ 'error': e.reason }) } + except Exception as e: return { 'statusCode': 500, 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