From 9666b38a486feed3d00817ebd98ab91fdf2f5fd9 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 13 Jun 2026 01:07:14 +0900 Subject: [PATCH 01/10] add tally onboarding intake workflow --- .env.example | 12 + apps/admin_dashboard/src/main.tsx | 165 +++++++++- apps/api/src/five08/backend/api.py | 297 +++++++++++++++++- apps/worker/src/five08/worker/config.py | 30 ++ .../worker/crm/intake_form_processor.py | 186 ++++++++++- ...00_create_onboarding_intake_submissions.py | 112 +++++++ apps/worker/src/five08/worker/models.py | 76 ++++- docs/configuration.md | 23 ++ packages/shared/src/five08/runtime_config.py | 30 ++ tests/unit/test_backend_api.py | 253 +++++++++++++++ tests/unit/test_intake_form_processor.py | 49 +++ tests/unit/test_runtime_config.py | 20 ++ tests/unit/test_worker_config.py | 42 +++ 13 files changed, 1283 insertions(+), 12 deletions(-) create mode 100644 apps/worker/src/five08/worker/migrations/versions/20260613_0200_create_onboarding_intake_submissions.py diff --git a/.env.example b/.env.example index 9caadf92..99a0fa81 100644 --- a/.env.example +++ b/.env.example @@ -237,6 +237,18 @@ INTAKE_RESUME_FETCH_TIMEOUT_SECONDS=20.0 INTAKE_RESUME_MAX_REDIRECTS=3 # Optional comma-separated host allowlist for intake resume URL fetches INTAKE_RESUME_ALLOWED_HOSTS= +# Onboarding Tally intake webhooks. ONBOARDING_TALLY_API_KEY is optional for +# future API backfills; webhook submissions use the signing secret for auth. +# Legacy aliases still work: TALLY_API_KEY, TALLY_WEBHOOK_SIGNING_SECRET, +# TALLY_ALLOWED_FORM_IDS. +ONBOARDING_TALLY_API_KEY= +ONBOARDING_TALLY_WEBHOOK_SIGNING_SECRET= +ONBOARDING_TALLY_ALLOWED_FORM_IDS= +# Resume files are untrusted. Keep scanning required in production and configure +# a scanner command; use "{path}" where the downloaded resume path should go. +INTAKE_RESUME_REQUIRE_VIRUS_SCAN=true +INTAKE_RESUME_VIRUS_SCAN_COMMAND= +INTAKE_RESUME_VIRUS_SCAN_TIMEOUT_SECONDS=30.0 EMAIL_RESUME_INTAKE_ENABLED=false EMAIL_RESUME_ALLOWED_EXTENSIONS=pdf,doc,docx EMAIL_RESUME_MAX_FILE_SIZE_MB=10 diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index eb4ae2ed..4e1ac252 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -175,6 +175,16 @@ type ProfileStatus = { skills_count?: number } +type IntakeSubmission = { + source?: string + form_id?: string + submission_id?: string + submitted_at?: string + created_at?: string + normalized_payload?: Record + raw_payload?: Record +} + type Person = { crm_contact_id?: string name?: string @@ -200,6 +210,7 @@ type Person = { onboarding_email_recipient?: string sync_status?: string profile_status?: ProfileStatus + latest_intake_submission?: IntakeSubmission } type OnboardingEmailTriState = "yes" | "no" | "unknown" @@ -1842,8 +1853,10 @@ function App() { ) setConfigurationItems(payload.items) showToast(`Saved ${key}`, "ok") + return true } catch (error) { showError(error, `Unable to save ${key}`) + return false } finally { setBusy(`configuration:${key}`, false) } @@ -5989,6 +6002,27 @@ function companyEmailFromPerson(person: Person) { return email.toLowerCase().endsWith("@508.dev") ? email : "" } +function intakePayloadValue(submission: IntakeSubmission | undefined, key: string): string { + const value = submission?.normalized_payload?.[key] + if (value === null || value === undefined) return "" + if (typeof value === "string") return value.trim() + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (Array.isArray(value)) return value.map((item) => String(item)).join(", ") + return "" +} + +function intakeSummaryItems(submission: IntakeSubmission | undefined) { + if (!submission?.normalized_payload) return [] + return [ + ["Native name", intakePayloadValue(submission, "native_name")], + ["Weekly hours", intakePayloadValue(submission, "ideal_weekly_hours")], + ["Chat times", intakePayloadValue(submission, "availability")], + ["Rate", intakePayloadValue(submission, "rate_range")], + ["Interest", intakePayloadValue(submission, "top_question_about_508")], + ["Skills/interests", intakePayloadValue(submission, "primary_skills_interests")], + ].filter(([, value]) => value) +} + function EngineerSetupPanel({ loading, onSetup, @@ -6308,6 +6342,8 @@ function OnboardingRow({ ].filter(([, ok]) => !ok) const contactUrl = crmContactUrl(person.crm_contact_id) const resumeUrl = crmAttachmentUrl(person.latest_resume_id) + const intakeSubmission = person.latest_intake_submission + const intakeItems = intakeSummaryItems(intakeSubmission) const emailSentAt = emailDraft?.onboarding_email_sent_at || person.onboarding_email_sent_at const emailSentBy = emailDraft?.onboarding_email_sent_by || person.onboarding_email_sent_by const emailSentRecipient = @@ -6371,6 +6407,31 @@ function OnboardingRow({
{person.email_508 || person.email || ""}
+ {intakeSubmission ? ( +
+ + Application + {intakeSubmission.source ? ` via ${intakeSubmission.source}` : ""} + {intakeSubmission.submitted_at + ? ` | ${formatDate(intakeSubmission.submitted_at)}` + : ""} + +
+ {intakeItems.length > 0 ? ( + intakeItems.map(([label, value]) => ( + + {label}: {value} + + )) + ) : ( + No extra application fields. + )} + {intakeSubmission.submission_id ? ( + Submission {intakeSubmission.submission_id} + ) : null} +
+
+ ) : null}
@@ -7113,7 +7174,7 @@ function ConfigurationView({ loading: Record canWrite: boolean onRefresh: () => void - onSave: (key: string, value: string) => void + onSave: (key: string, value: string) => Promise onClear: (key: string) => void focusCategory?: string focusNonce?: number @@ -7121,6 +7182,7 @@ function ConfigurationView({ const [selectedCategory, setSelectedCategory] = useState("All") const [highlightedCategory, setHighlightedCategory] = useState("") const [drafts, setDrafts] = useState>({}) + const [generatedSecrets, setGeneratedSecrets] = useState>({}) const categories = useMemo(() => { const present = new Set(items.map((item) => item.category)) const known = configurationGroups.filter((group) => present.has(group.category)) @@ -7194,10 +7256,13 @@ function ConfigurationView({ setSelectedCategory(focusCategory) setHighlightedCategory(focusCategory) const frame = window.requestAnimationFrame?.(() => { - document.getElementById(configurationGroupId(focusCategory))?.scrollIntoView({ - block: "start", - behavior: "smooth", - }) + const element = document.getElementById(configurationGroupId(focusCategory)) + if (typeof element?.scrollIntoView === "function") { + element.scrollIntoView({ + block: "start", + behavior: "smooth", + }) + } }) const timeout = window.setTimeout(() => setHighlightedCategory(""), 4000) return () => { @@ -7212,6 +7277,32 @@ function ConfigurationView({ return "Default" } + function generateSigningSecret() { + const bytes = new Uint8Array(32) + window.crypto.getRandomValues(bytes) + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("") + } + + async function generateTallySigningSecret(item: ConfigurationItem) { + const secret = generateSigningSecret() + setDrafts((current) => ({ ...current, [item.key]: secret })) + const saved = await onSave(item.key, secret) + if (saved) { + setGeneratedSecrets((current) => ({ ...current, [item.key]: secret })) + } + } + + async function copyGeneratedSecret(key: string) { + const secret = generatedSecrets[key] + if (!secret) return + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(secret) + return + } + const element = document.getElementById(`generatedSecret-${key}`) as HTMLInputElement | null + element?.select() + } + function valueInput(item: ConfigurationItem) { const value = drafts[item.key] ?? "" const disabled = !canWrite || item.env_locked || loading[`configuration:${item.key}`] @@ -7277,6 +7368,8 @@ function ConfigurationView({ const writable = canWrite && !item.env_locked && !busy const draft = drafts[item.key] ?? "" const emptyNonSecretDraft = !item.is_secret && !draft.trim() + const generatedSecret = generatedSecrets[item.key] || "" + const showTallySecretGenerator = item.key === "ONBOARDING_TALLY_WEBHOOK_SIGNING_SECRET" return ( @@ -7320,9 +7413,69 @@ function ConfigurationView({ ) : null}
- {valueInput(item)} + +
+ {valueInput(item)} + {showTallySecretGenerator && generatedSecret ? ( +
+ Copy this secret into Tally now. + + + It is only shown until this page refreshes or you dismiss it. + +
+ ) : null} +
+
+ {showTallySecretGenerator ? ( + + ) : null} + {showTallySecretGenerator && generatedSecret ? ( + <> + + + + ) : null}