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 && (
+
+ )}
+
+
+ );
+}
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
zcmb_hT~HfYcJ5ZUq}IRq$AF{|A1}4*u?+_+cUrdGmf3zp@g~t$wJb0x4>vy
zS$31Ez>{(9roc6xvNlzzWolftJo+uOGdq>7WF8ue3Ys1soWw8l#$u;t%lndZZ>uHY
zkv*wY8g#q+{+x5~x#!;deW(A;;jj{L{cKJS{ZkD={1#W*~y6CA;jVdA!S
zCvTJZ-EiB0@6>G?-;LS33GRl!BW{~H3Q~rnA+>NuNUfX+Qri;gF@K8`j0DK69&|Io
zkAmNe1IS$`q+A(CewuwI37HVx1to@j2}f}>XMAXwASAu_M2JR$4__hk;dk^VS@gUR
zwu%0x-8qx4MJ71&!<^2L<8VFn#xZ)`Bw5f;i=Ims_RjhJ_MwvR=W$k<f=cifAbP`EQs?f_rmHJcuE>oc+`8WOP)9gd2R?o@b^)h{=
zNm`}@$V1>y*Gm!;L=$m_5XnUYN!%hx_*)|GQn!dj(&HLQw6w#;3VeUx*ku23AG^Q{
z!I@ydF9o9!R+RjL#B(gFlc*mYA0Hj>X5R~nVlXmW&_;^h;UiZQ4!s1wdkm^sj0Xa|
zD5^$Y5Tb%wK0cULGc_^TKR!4)bz|@r)H8{EK;R{n5qL2cjfng!V_cePznJijjZRD=
zT~d@CibiL{e1TTn(ugl|rgVq&+?x45|Ioy85jf#|$X-~;}c6qxh-
z{IQ@<3`)G#Sf!By=mE|7CBJH#=cTzQ_pK33>>*XN6rAUyamnLUjWGdKsHV6O4hN^<
z)h~#=YJpci5{PoVYKV%eX_lAx$bxFh=9vZlr*U4CRP(t001cusKBCegF&a^g;i#Vz
zRa)TvoND5DB*|k`jo5yI4SJzcAOj^J9g=uh5>e+^biL~G%|+*V-@IRvd>p?3ql^TC
zzG*JtTR7_zd0`<4#`n!YyE%>*@A&5Y5iZON-q@1r*2X`TGmLNy#4_*`{~eNLVvn{c
z){Y&z^FM6vJ;tFpy*rFAQ_F5%*>?3POwV&$$-PUfmv(K9+qTBN((-lBeUDOiHeGry
zQ-0)6Mykp5f}rf??@R>a{Kdk`?BD);-%J=SFRg^ps#v>s=$_|R_dV~bch|~pTiFcL
zm2s40Toq8u*7$;;ZKmb^mv#c`)UVd>S{k-34Jm5#M~bB(ZSn0B24m&cxxJE#^_u%N
z8~u-l9}ItVeYdWCyRJQ5=S!EIUNM2J?~G9GJ5=`oC9gy~4DIvN=la{JFWQa$=go)q
zLbPw;y)Xg~>#*yEa=Rg9WG{{k=2lkG^4w09BxebZ`bRQpKzk%-f?bSCQZgwU76`!z
zcZy>!fmE5AAUu{4)jS7IzzZ*Q>#5g@&`reteNALy-?Y!+M<$1HH(od1uM`)!*;U(-4jlLc~zGmDdV{*&8(tu
z9CAf8*=P+3ji`=25EaC!>;wpHs0@DM7$nO?#!-6j*6OXbrPQ&sCLnZW~xv9$xORU%R~EiaQn4}Z8MuWb794-xZAfG?=xHJ^2iI2
zNd$7ICA^@s5&PXQ;WR#c220t~hbVfg%7nh}=l6Q>+9GEyXDIO6V&&8il+(JVyQElw
z5J6Eb#)4XK)^Mhz;SDv-1vN|GD39SR`p97AP@x=RKsMAasF7WzA+%k-cIoFMoV+>Q
zkhfZrhEtwiAO(ly(i?I%&MwpXsSmg&hYdoddY$~8bLiTW#viEZ%+)l>M!_JP#6qnu
z&V5Mh+i`;NBZ4dW*zh*Aplf>@YDk*7QrQHu%08x&44{?rJ1qnun79g=<|?_Whp;n2
zZZ*zb{{NfF0*rP
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
zcmb_hT~HfYcJ5ZUq}IRq$AF{|A1}4*u?+_+cUrdGmf3zp@g~t$wJb0x4>vy
zS$31Ez>{(9roc6xvNlzzWolftJo+uOGdq>7WF8ue3Ys1soWw8l#$u;t%lndZZ>uHY
zkv*wY8g#q+{+x5~x#!;deW(A;;jj{L{cKJS{ZkD={1#W*~y6CA;jVdA!S
zCvTJZ-EiB0@6>G?-;LS33GRl!BW{~H3Q~rnA+>NuNUfX+Qri;gF@K8`j0DK69&|Io
zkAmNe1IS$`q+A(CewuwI37HVx1to@j2}f}>XMAXwASAu_M2JR$4__hk;dk^VS@gUR
zwu%0x-8qx4MJ71&!<^2L<8VFn#xZ)`Bw5f;i=Ims_RjhJ_MwvR=W$k<f=cifAbP`EQs?f_rmHJcuE>oc+`8WOP)9gd2R?o@b^)h{=
zNm`}@$V1>y*Gm!;L=$m_5XnUYN!%hx_*)|GQn!dj(&HLQw6w#;3VeUx*ku23AG^Q{
z!I@ydF9o9!R+RjL#B(gFlc*mYA0Hj>X5R~nVlXmW&_;^h;UiZQ4!s1wdkm^sj0Xa|
zD5^$Y5Tb%wK0cULGc_^TKR!4)bz|@r)H8{EK;R{n5qL2cjfng!V_cePznJijjZRD=
zT~d@CibiL{e1TTn(ugl|rgVq&+?x45|Ioy85jf#|$X-~;}c6qxh-
z{IQ@<3`)G#Sf!By=mE|7CBJH#=cTzQ_pK33>>*XN6rAUyamnLUjWGdKsHV6O4hN^<
z)h~#=YJpci5{PoVYKV%eX_lAx$bxFh=9vZlr*U4CRP(t001cusKBCegF&a^g;i#Vz
zRa)TvoND5DB*|k`jo5yI4SJzcAOj^J9g=uh5>e+^biL~G%|+*V-@IRvd>p?3ql^TC
zzG*JtTR7_zd0`<4#`n!YyE%>*@A&5Y5iZON-q@1r*2X`TGmLNy#4_*`{~eNLVvn{c
z){Y&z^FM6vJ;tFpy*rFAQ_F5%*>?3POwV&$$-PUfmv(K9+qTBN((-lBeUDOiHeGry
zQ-0)6Mykp5f}rf??@R>a{Kdk`?BD);-%J=SFRg^ps#v>s=$_|R_dV~bch|~pTiFcL
zm2s40Toq8u*7$;;ZKmb^mv#c`)UVd>S{k-34Jm5#M~bB(ZSn0B24m&cxxJE#^_u%N
z8~u-l9}ItVeYdWCyRJQ5=S!EIUNM2J?~G9GJ5=`oC9gy~4DIvN=la{JFWQa$=go)q
zLbPw;y)Xg~>#*yEa=Rg9WG{{k=2lkG^4w09BxebZ`bRQpKzk%-f?bSCQZgwU76`!z
zcZy>!fmE5AAUu{4)jS7IzzZ*Q>#5g@&`reteNALy-?Y!+M<$1HH(od1uM`)!*;U(-4jlLc~zGmDdV{*&8(tu
z9CAf8*=P+3ji`=25EaC!>;wpHs0@DM7$nO?#!-6j*6OXbrPQ&sCLnZW~xv9$xORU%R~EiaQn4}Z8MuWb794-xZAfG?=xHJ^2iI2
zNd$7ICA^@s5&PXQ;WR#c220t~hbVfg%7nh}=l6Q>+9GEyXDIO6V&&8il+(JVyQElw
z5J6Eb#)4XK)^Mhz;SDv-1vN|GD39SR`p97AP@x=RKsMAasF7WzA+%k-cIoFMoV+>Q
zkhfZrhEtwiAO(ly(i?I%&MwpXsSmg&hYdoddY$~8bLiTW#viEZ%+)l>M!_JP#6qnu
z&V5Mh+i`;NBZ4dW*zh*Aplf>@YDk*7QrQHu%08x&44{?rJ1qnun79g=<|?_Whp;n2
zZZ*zb{{NfF0*rP