From 94a4a5017a2cda66761e3630e750fabe1a8d33c9 Mon Sep 17 00:00:00 2001 From: suejinkim20 Date: Fri, 31 Jul 2026 14:09:29 -0400 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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'; From 481145e9e85482967ef468542880f1804ec65ad2 Mon Sep 17 00:00:00 2001 From: Matt Watson Date: Wed, 12 Aug 2026 09:21:49 -0400 Subject: [PATCH 6/7] refactor join logic into own service - Added shared services doc: services/README.md - explains one services/.env for both local Python services - includes startup commands for both ports 8787 and 8788 - includes apps/site/.env wiring (FRESHDESK_PROXY_URL, FRESHDESK_JOIN_URL) - Updated env loading in both local servers to support a shared env file: - services/freshdesk/server.py - services/freshdesk-join/server.py - load order is now: 1. services/.env (shared) 2. optional service-specific override (services/freshdesk/.env or services/freshdesk-join/.env) - Updated docs in each service to match shared-env workflow: - services/freshdesk/README.md - services/freshdesk-join/README.md - Setup shared .env and added shared sample template: - services/sample.env - Removed duplicate per-service env templates: - deleted services/freshdesk/sample.env - deleted services/freshdesk-join/sample.env --- services/README.md | 58 ++++++ services/freshdesk-join/README.md | 44 +++++ services/freshdesk-join/handler.py | 177 ++++++++++++++++++ services/freshdesk-join/requirements.txt | 1 + services/freshdesk-join/server.py | 53 ++++++ services/freshdesk/README.md | 8 +- .../__pycache__/handler.cpython-312.pyc | Bin 0 -> 8525 bytes services/freshdesk/handler.py | 9 - services/freshdesk/join_handler.py | 117 ------------ services/freshdesk/server.py | 7 +- services/sample.env | 3 + 11 files changed, 348 insertions(+), 129 deletions(-) create mode 100644 services/README.md create mode 100644 services/freshdesk-join/README.md create mode 100644 services/freshdesk-join/handler.py create mode 100644 services/freshdesk-join/requirements.txt create mode 100644 services/freshdesk-join/server.py create mode 100644 services/freshdesk/__pycache__/handler.cpython-312.pyc delete mode 100644 services/freshdesk/join_handler.py create mode 100644 services/sample.env diff --git a/services/README.md b/services/README.md new file mode 100644 index 00000000..cfb312e7 --- /dev/null +++ b/services/README.md @@ -0,0 +1,58 @@ +# Services + +Local service wrappers for Lambda handlers used by the site. + +## Shared Configuration + +Copy `sample.env` to `.env` in this directory: + +```bash +cp services/sample.env services/.env +``` + +Set the required values in `services/.env`: + +``` +FRESHDESK_DOMAIN= +FRESHDESK_API_KEY= +RECAPTCHA_SECRET_KEY= +``` + +Both service runners load `services/.env` automatically. You can add optional service-specific overrides in: + +- `services/freshdesk/.env` +- `services/freshdesk-join/.env` + +## Start Services + +Run each service in its own terminal. + +### Freshdesk proxy (ticket forms, FAQs, custom object forms) + +```bash +cd services/freshdesk +pipenv install +pipenv run python server.py +``` + +Serves: `http://localhost:8787` + +### Freshdesk join Lambda (join form only) + +```bash +cd services/freshdesk-join +pipenv install +pipenv run python server.py +``` + +Serves: `http://localhost:8788` + +## Site Env Wiring + +Set these in `apps/site/.env`: + +``` +FRESHDESK_PROXY_URL=http://localhost:8787 +FRESHDESK_JOIN_URL=http://localhost:8788 +PUBLIC_RECAPTCHA_SITE_KEY= +``` diff --git a/services/freshdesk-join/README.md b/services/freshdesk-join/README.md new file mode 100644 index 00000000..1aea16d9 --- /dev/null +++ b/services/freshdesk-join/README.md @@ -0,0 +1,44 @@ +# Freshdesk Join Lambda + +This service is the source of truth for the dedicated Join Lambda used by `apps/site/src/pages/join.astro`. + +Unlike `services/freshdesk/handler.py`, this handler includes join-specific business logic: + +- verifies reCAPTCHA +- rejects bot submissions via honeypot +- searches contacts by email +- updates existing contacts +- creates contacts when no existing record is found +- returns `409` with `already_exists` for duplicate create races + +## Local Development + +### Setup + +```bash +pip install -r requirements.txt +``` + +Copy `services/sample.env` to `services/.env` and fill the required variables: + +``` +FRESHDESK_API_KEY= +FRESHDESK_DOMAIN= +RECAPTCHA_SECRET_KEY= +``` + +Optional: create `services/freshdesk-join/.env` only if you need service-specific overrides. + +### Run + +```bash +python server.py +``` + +The server listens on `http://localhost:8788`. + +To route the site join page through local join Lambda, set `FRESHDESK_JOIN_URL=http://localhost:8788` in `apps/site/.env`. + +## Deployment + +Deploy `handler.py` from this directory as the Join Lambda function code. diff --git a/services/freshdesk-join/handler.py b/services/freshdesk-join/handler.py new file mode 100644 index 00000000..f1872b54 --- /dev/null +++ b/services/freshdesk-join/handler.py @@ -0,0 +1,177 @@ +import os +import json +import base64 +import urllib.request +import urllib.error +import urllib.parse + + +def verify_recaptcha(token): + if not token: + return {'success': False, 'error': 'Missing token'} + + data = urllib.parse.urlencode({ + 'secret': os.getenv('RECAPTCHA_SECRET_KEY'), + 'response': token, + }).encode('utf-8') + + req = urllib.request.Request( + 'https://www.google.com/recaptcha/api/siteverify', + data=data, + method='POST', + ) + + with urllib.request.urlopen(req, timeout=5) as res: + return json.loads(res.read().decode()) + + +def cors_headers(event): + origin = event.get('headers', {}).get('origin') + allowed_origins = [ + 'https://biodatacatalyst.nhlbi.nih.gov', + 'https://staging.biodatacatalyst.nhlbi.nih.gov', + 'http://localhost:4321', + ] + + headers = { + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Content-Type': 'application/json', + } + + if origin in allowed_origins: + headers['Access-Control-Allow-Origin'] = origin + + return headers + + +def _freshdesk_request(url, method, body, auth, headers): + req = urllib.request.Request(url, method=method) + req.add_header('Authorization', f'Basic {auth}') + req.add_header('Content-Type', 'application/json') + + try: + with urllib.request.urlopen(req, data=body) as res: + return { + 'statusCode': res.getcode(), + 'headers': headers, + 'body': res.read().decode(), + } + except urllib.error.HTTPError as e: + error = e.read().decode() + print('[JOIN] Freshdesk error response:', error) + return { + 'statusCode': e.code, + 'headers': headers, + 'body': json.dumps({'error': e.reason}), + } + except Exception as e: + return { + 'statusCode': 500, + 'headers': headers, + 'body': json.dumps({'error': str(e)}), + } + + +def _error(code, message, headers): + return { + 'statusCode': code, + 'headers': headers, + 'body': json.dumps({'error': message}), + } + + +def lambda_handler(event, context): + print('[JOIN] Received event:', json.dumps(event)) + + method = ( + event.get('requestContext', {}).get('http', {}).get('method') + or event.get('httpMethod') + or 'UNKNOWN' + ) + + headers = cors_headers(event) + if 'Access-Control-Allow-Origin' not in headers: + return _error(403, 'CORS origin not allowed', headers) + + if method == 'OPTIONS': + return {'statusCode': 204, 'headers': headers, 'body': ''} + + if method != 'POST': + return _error(405, f'Method {method} not allowed', headers) + + api_key = os.getenv('FRESHDESK_API_KEY') + domain = os.getenv('FRESHDESK_DOMAIN') + if not api_key or not domain: + return _error(500, 'Missing FRESHDESK_API_KEY or FRESHDESK_DOMAIN', headers) + + body = event.get('body') + if not body: + return _error(400, 'Missing request body', headers) + + try: + payload = json.loads(body) + except json.JSONDecodeError: + return _error(400, 'Invalid JSON body', headers) + + recaptcha_token = payload.get('recaptcha_token') + if not recaptcha_token: + return _error(400, 'Missing reCAPTCHA token', headers) + + verification = verify_recaptcha(recaptcha_token) + if not verification.get('success'): + return _error(403, 'reCAPTCHA verification failed', headers) + + payload.pop('recaptcha_token', None) + + if payload.pop('website', ''): + print('[JOIN] Honeypot field populated; discarding') + return {'statusCode': 200, 'headers': headers, 'body': json.dumps({'message': 'ok'})} + + email = payload.get('email') + if not email: + return _error(400, 'Missing email', headers) + + auth = base64.b64encode(f'{api_key}:X'.encode()).decode() + base_url = f'https://{domain}/api/v2' + + 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] Contact search failed ({e.code}): {error}') + return { + 'statusCode': e.code, + 'headers': headers, + 'body': json.dumps({'error': e.reason}), + } + except Exception as e: + return { + 'statusCode': 500, + 'headers': headers, + 'body': json.dumps({'error': str(e)}), + } + + request_body = json.dumps(payload).encode('utf-8') + + if contacts: + contact_id = contacts[0].get('id') + update_url = f'{base_url}/contacts/{contact_id}' + return _freshdesk_request(update_url, 'PUT', request_body, auth, headers) + + create_url = f'{base_url}/contacts' + result = _freshdesk_request(create_url, 'POST', request_body, auth, headers) + if result.get('statusCode') == 409: + return { + 'statusCode': 409, + 'headers': headers, + 'body': json.dumps({'error': 'already_exists'}), + } + + return result diff --git a/services/freshdesk-join/requirements.txt b/services/freshdesk-join/requirements.txt new file mode 100644 index 00000000..566cccb8 --- /dev/null +++ b/services/freshdesk-join/requirements.txt @@ -0,0 +1 @@ +python-dotenv diff --git a/services/freshdesk-join/server.py b/services/freshdesk-join/server.py new file mode 100644 index 00000000..ce924f68 --- /dev/null +++ b/services/freshdesk-join/server.py @@ -0,0 +1,53 @@ +""" +Local development server for the join Lambda handler. + +Usage: + pip install -r requirements.txt + python server.py +""" + +from pathlib import Path +from http.server import HTTPServer, BaseHTTPRequestHandler +from dotenv import load_dotenv +from handler import lambda_handler + +service_dir = Path(__file__).resolve().parent + +# Shared local config for all services. +load_dotenv(service_dir.parent / '.env', override=False) +# Optional service-specific overrides. +load_dotenv(service_dir / '.env', override=True) + +PORT = 8788 + + +class Handler(BaseHTTPRequestHandler): + def _handle(self, method): + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode() if content_length else None + + event = { + 'httpMethod': method, + 'path': self.path, + 'headers': {k.lower(): v for k, v in self.headers.items()}, + 'body': body, + } + + result = lambda_handler(event, None) + + self.send_response(result['statusCode']) + for k, v in result.get('headers', {}).items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(result.get('body', '').encode()) + + def do_POST(self): + self._handle('POST') + + def do_OPTIONS(self): + self._handle('OPTIONS') + + +if __name__ == '__main__': + print(f'Freshdesk join proxy listening on http://localhost:{PORT}') + HTTPServer(('', PORT), Handler).serve_forever() diff --git a/services/freshdesk/README.md b/services/freshdesk/README.md index 5b978087..ce998157 100644 --- a/services/freshdesk/README.md +++ b/services/freshdesk/README.md @@ -2,6 +2,8 @@ This is the source of truth for the AWS Lambda function that proxies requests between the site and the Freshdesk API. The Lambda handles CORS, reCAPTCHA verification, and authenticated forwarding to Freshdesk. +This handler is intentionally a generic proxy. Join-specific contact logic lives in `services/freshdesk-join/handler.py`. + The site references this proxy via the `FRESHDESK_PROXY_URL` environment variable (with a fallback to the production Lambda URL), so **most site development does not require running this service locally**. ## Local Development @@ -14,7 +16,7 @@ The local server is only needed when working on the Lambda function itself. It w pip install -r requirements.txt ``` -Copy `.env` and fill in your credentials: +Copy `services/sample.env` to `services/.env` and fill the required variables: ``` FRESHDESK_API_KEY= @@ -22,6 +24,8 @@ FRESHDESK_DOMAIN= RECAPTCHA_SECRET_KEY= ``` +Optional: create `services/freshdesk/.env` only if you need service-specific overrides. + ### Run ```bash @@ -30,7 +34,7 @@ python server.py The server starts on `http://localhost:8787`. -To route the site through the local proxy, set `FRESHDESK_PROXY_URL=http://localhost:8787` in `apps/site/.env`. +To route non-join site forms through the local proxy, set `FRESHDESK_PROXY_URL=http://localhost:8787` in `apps/site/.env`. ## Deployment diff --git a/services/freshdesk/__pycache__/handler.cpython-312.pyc b/services/freshdesk/__pycache__/handler.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98cabbfcc2115fff23ce027364b9983a04d5cc4e GIT binary patch literal 8525 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 literal 0 HcmV?d00001 diff --git a/services/freshdesk/handler.py b/services/freshdesk/handler.py index be1b34b9..4cb926a9 100644 --- a/services/freshdesk/handler.py +++ b/services/freshdesk/handler.py @@ -5,8 +5,6 @@ import urllib.error import urllib.parse -from join_handler import handle_join - def verify_recaptcha(token): print("---- reCAPTCHA verification started ----") @@ -36,7 +34,6 @@ def cors_headers(event): allowed_origins = [ 'https://biodatacatalyst.nhlbi.nih.gov', 'https://staging.biodatacatalyst.nhlbi.nih.gov', - 'http://localhost:8000', 'http://localhost:4321', ] @@ -141,12 +138,6 @@ def lambda_handler(event, context): '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', diff --git a/services/freshdesk/join_handler.py b/services/freshdesk/join_handler.py deleted file mode 100644 index ff7a7678..00000000 --- a/services/freshdesk/join_handler.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -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 diff --git a/services/freshdesk/server.py b/services/freshdesk/server.py index e3dcf3a7..0531f121 100644 --- a/services/freshdesk/server.py +++ b/services/freshdesk/server.py @@ -13,7 +13,12 @@ from dotenv import load_dotenv from handler import lambda_handler -load_dotenv(Path(__file__).resolve().parent / '.env', override=True) +service_dir = Path(__file__).resolve().parent + +# Shared local config for all services. +load_dotenv(service_dir.parent / '.env', override=False) +# Optional service-specific overrides. +load_dotenv(service_dir / '.env', override=True) PORT = 8787 diff --git a/services/sample.env b/services/sample.env new file mode 100644 index 00000000..08c6a5d3 --- /dev/null +++ b/services/sample.env @@ -0,0 +1,3 @@ +FRESHDESK_DOMAIN= +FRESHDESK_API_KEY= +RECAPTCHA_SECRET_KEY= From 2bd6c756e1e71c97773fac42990442dc1c987b34 Mon Sep 17 00:00:00 2001 From: Matt Watson Date: Wed, 12 Aug 2026 09:29:42 -0400 Subject: [PATCH 7/7] rename freshdesk service to freshdesk-proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed services/freshdesk → services/freshdesk-proxy - Updated path references in docs: - README.md:117 - services/README.md:23 - services/freshdesk-proxy/README.md:27 - services/freshdesk-join/README.md:5 - Updated code comments that pointed to old path: - apps/site/src/components/forms/DynamicForm.tsx:194 - apps/site/src/components/forms/DynamicCustomObjectForm.tsx:179 - Removed moved Python cache dirs so they don’t linger under the new folder. --- README.md | 4 ++-- apps/site/sample.env | 4 ++-- .../components/forms/DynamicCustomObjectForm.tsx | 2 +- apps/site/src/components/forms/DynamicForm.tsx | 2 +- .../components/forms/FreshdeskContactForm.tsx | 6 +++--- apps/site/src/pages/join.astro | 2 +- services/README.md | 4 ++-- services/freshdesk-join/README.md | 2 +- .../{freshdesk => freshdesk-proxy}/README.md | 2 +- .../{freshdesk => freshdesk-proxy}/handler.py | 0 .../requirements.txt | 0 .../{freshdesk => freshdesk-proxy}/server.py | 0 .../__pycache__/handler.cpython-312.pyc | Bin 8525 -> 0 bytes 13 files changed, 14 insertions(+), 14 deletions(-) rename services/{freshdesk => freshdesk-proxy}/README.md (93%) rename services/{freshdesk => freshdesk-proxy}/handler.py (100%) rename services/{freshdesk => freshdesk-proxy}/requirements.txt (100%) rename services/{freshdesk => freshdesk-proxy}/server.py (100%) delete mode 100644 services/freshdesk/__pycache__/handler.cpython-312.pyc diff --git a/README.md b/README.md index 36f88601..63d7f9b6 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,9 @@ npm run preview -w @bdc/consortium ## Services -### services/freshdesk +### services/freshdesk-proxy -Source of truth for the AWS Lambda that proxies requests to the Freshdesk API. Only needed locally when developing the Lambda itself. See [services/freshdesk/README.md](services/freshdesk/README.md) for setup and usage. +Source of truth for the AWS Lambda that proxies requests to the Freshdesk API. Only needed locally when developing the Lambda itself. See [services/freshdesk-proxy/README.md](services/freshdesk-proxy/README.md) for setup and usage. --- diff --git a/apps/site/sample.env b/apps/site/sample.env index 13cf2c13..4d724cb6 100644 --- a/apps/site/sample.env +++ b/apps/site/sample.env @@ -1,8 +1,8 @@ FRESHDESK_PROXY_URL= -FRESHDESK_PROXY_URL= +FRESHDESK_JOIN_URL= FRESHDESK_DOMAIN= FRESHDESK_API_KEY= FRESHDESK_CUSTOM_OBJECT_PUBLICATIONS_SCHEMA_ID= FRESHDESK_CUSTOM_OBJECT_CONTACT_BDC_SCHEMA_ID= FRESHDESK_FORM_GET_HELP= -PUBLIC_RECAPTCHA_SITE_KEY= \ No newline at end of file +PUBLIC_RECAPTCHA_SITE_KEY= diff --git a/apps/site/src/components/forms/DynamicCustomObjectForm.tsx b/apps/site/src/components/forms/DynamicCustomObjectForm.tsx index a14695d1..fe8916fc 100644 --- a/apps/site/src/components/forms/DynamicCustomObjectForm.tsx +++ b/apps/site/src/components/forms/DynamicCustomObjectForm.tsx @@ -176,7 +176,7 @@ export default function DynamicCustomObjectForm({ try { // Get reCAPTCHA token before building payload. // The Lambda proxy verifies this token with Google before forwarding - // the record to Freshdesk. See services/freshdesk/handler.py. + // the record to Freshdesk. See services/freshdesk-proxy/handler.py. const recaptchaToken = await getRecaptchaToken(recaptchaSiteKey); const payload = { diff --git a/apps/site/src/components/forms/DynamicForm.tsx b/apps/site/src/components/forms/DynamicForm.tsx index 6f6aa474..f2f85857 100644 --- a/apps/site/src/components/forms/DynamicForm.tsx +++ b/apps/site/src/components/forms/DynamicForm.tsx @@ -191,7 +191,7 @@ export default function DynamicForm({ try { // Get reCAPTCHA token before building payload. // The Lambda proxy verifies this token with Google before forwarding - // the ticket to Freshdesk. See services/freshdesk/handler.py. + // the ticket to Freshdesk. See services/freshdesk-proxy/handler.py. const recaptchaToken = await getRecaptchaToken(recaptchaSiteKey); const payload = { diff --git a/apps/site/src/components/forms/FreshdeskContactForm.tsx b/apps/site/src/components/forms/FreshdeskContactForm.tsx index 387b5b81..5e024717 100644 --- a/apps/site/src/components/forms/FreshdeskContactForm.tsx +++ b/apps/site/src/components/forms/FreshdeskContactForm.tsx @@ -18,8 +18,8 @@ type FormStatus = | 'error'; interface FreshdeskContactFormProps { - // The Lambda proxy endpoint URL. - // Set via FRESHDESK_PROXY_URL in apps/site/.env. + // The Join Lambda endpoint URL. + // Set via FRESHDESK_JOIN_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. @@ -65,7 +65,7 @@ export default function FreshdeskContactForm({ recaptcha_token: recaptchaToken, }; - const response = await fetch(`${submitUrl}/join`, { + const response = await fetch(submitUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), diff --git a/apps/site/src/pages/join.astro b/apps/site/src/pages/join.astro index 137cb277..039e45b1 100644 --- a/apps/site/src/pages/join.astro +++ b/apps/site/src/pages/join.astro @@ -16,7 +16,7 @@ import Base from '@layouts/Base.astro';

diff --git a/services/README.md b/services/README.md index cfb312e7..c611beb5 100644 --- a/services/README.md +++ b/services/README.md @@ -20,7 +20,7 @@ RECAPTCHA_SECRET_KEY= Both service runners load `services/.env` automatically. You can add optional service-specific overrides in: -- `services/freshdesk/.env` +- `services/freshdesk-proxy/.env` - `services/freshdesk-join/.env` ## Start Services @@ -30,7 +30,7 @@ Run each service in its own terminal. ### Freshdesk proxy (ticket forms, FAQs, custom object forms) ```bash -cd services/freshdesk +cd services/freshdesk-proxy pipenv install pipenv run python server.py ``` diff --git a/services/freshdesk-join/README.md b/services/freshdesk-join/README.md index 1aea16d9..be2487d0 100644 --- a/services/freshdesk-join/README.md +++ b/services/freshdesk-join/README.md @@ -2,7 +2,7 @@ This service is the source of truth for the dedicated Join Lambda used by `apps/site/src/pages/join.astro`. -Unlike `services/freshdesk/handler.py`, this handler includes join-specific business logic: +Unlike `services/freshdesk-proxy/handler.py`, this handler includes join-specific business logic: - verifies reCAPTCHA - rejects bot submissions via honeypot diff --git a/services/freshdesk/README.md b/services/freshdesk-proxy/README.md similarity index 93% rename from services/freshdesk/README.md rename to services/freshdesk-proxy/README.md index ce998157..1eca2c2e 100644 --- a/services/freshdesk/README.md +++ b/services/freshdesk-proxy/README.md @@ -24,7 +24,7 @@ FRESHDESK_DOMAIN= RECAPTCHA_SECRET_KEY= ``` -Optional: create `services/freshdesk/.env` only if you need service-specific overrides. +Optional: create `services/freshdesk-proxy/.env` only if you need service-specific overrides. ### Run diff --git a/services/freshdesk/handler.py b/services/freshdesk-proxy/handler.py similarity index 100% rename from services/freshdesk/handler.py rename to services/freshdesk-proxy/handler.py diff --git a/services/freshdesk/requirements.txt b/services/freshdesk-proxy/requirements.txt similarity index 100% rename from services/freshdesk/requirements.txt rename to services/freshdesk-proxy/requirements.txt diff --git a/services/freshdesk/server.py b/services/freshdesk-proxy/server.py similarity index 100% rename from services/freshdesk/server.py rename to services/freshdesk-proxy/server.py diff --git a/services/freshdesk/__pycache__/handler.cpython-312.pyc b/services/freshdesk/__pycache__/handler.cpython-312.pyc deleted file mode 100644 index 98cabbfcc2115fff23ce027364b9983a04d5cc4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8525 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