diff --git a/.env.example b/.env.example index 9caadf92..ddc61cd4 100644 --- a/.env.example +++ b/.env.example @@ -237,6 +237,19 @@ 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= +# Required for Tally webhook intake; comma-separated accepted onboarding form IDs. +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=false +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 7059a010..bb62330a 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -176,7 +176,17 @@ type ProfileStatus = { skills_count?: number } +type IntakeSubmission = { + source?: string + form_id?: string + submission_id?: string + submitted_at?: string + created_at?: string + normalized_payload?: Record +} + type Person = { + id?: string crm_contact_id?: string name?: string email?: string @@ -201,6 +211,7 @@ type Person = { onboarding_email_recipient?: string sync_status?: string profile_status?: ProfileStatus + latest_intake_submission?: IntakeSubmission } type OnboardingEmailTriState = "yes" | "no" | "unknown" @@ -1845,8 +1856,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) } @@ -5673,6 +5686,12 @@ function PeopleView(props: { const status = person.profile_status || {} const skillsCount = Number(status.skills_count || 0) const resumeUrl = props.crmAttachmentUrl(person.latest_resume_id) + const intakeSubmission = person.latest_intake_submission + const intakeResumeHref = intakeResumeUrl(intakeSubmission) + const resumeHref = resumeUrl || intakeResumeHref + const resumeLabel = resumeUrl + ? "Resume" + : intakeResumeName(intakeSubmission) || person.latest_resume_name || "Resume" return ( @@ -5721,15 +5740,16 @@ function PeopleView(props: {
- {resumeUrl ? ( + {resumeHref ? ( - Resume + {resumeLabel} ) : ( @@ -5954,7 +5974,12 @@ function OnboardingView(props: { {props.people.map((person) => ( String(item)).join(", ") + return "" +} + +function intakeResumeUrl(submission: IntakeSubmission | undefined) { + const value = intakePayloadValue(submission, "resume_url") + if (!/^https:\/\//i.test(value)) return "" + return value +} + +function intakeResumeName(submission: IntakeSubmission | undefined) { + const fileName = intakePayloadValue(submission, "resume_file_name") + if (fileName) return fileName + const value = intakeResumeUrl(submission) + if (!value) return "" + try { + const pathName = new URL(value).pathname + return decodeURIComponent(pathName.split("/").filter(Boolean).pop() || "Resume") + } catch { + return "Resume" + } +} + +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, "chat_availability") || + 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, @@ -6323,6 +6392,7 @@ function OnboardingRow({ }) useEffect(() => setValue(displayOnboarder(person.onboarder)), [person.onboarder]) const currentStatus = normalizedOnboardingStatusValue(onboardingStateValue(person)) + const hasCrmContact = Boolean(person.crm_contact_id) const status = person.profile_status || {} const gaps = [ ["Discord", status.discord_linked], @@ -6331,6 +6401,13 @@ 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 intakeResumeHref = intakeResumeUrl(intakeSubmission) + const resumeHref = resumeUrl || intakeResumeHref + const resumeLabel = resumeUrl + ? "Resume" + : intakeResumeName(intakeSubmission) || person.latest_resume_name || "Resume" + 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 = @@ -6394,6 +6471,36 @@ function OnboardingRow({
{person.email_508 || person.email || ""}
+ {!hasCrmContact ? ( +
+ Application only +
+ ) : null} + {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}
@@ -6401,7 +6508,7 @@ function OnboardingRow({ {person.onboarding_status_label || labelForOnboardingState(onboardingStateValue(person))} - {canWrite ? ( + {canWrite && hasCrmContact ? ( + + It is only shown until this page refreshes or you dismiss it. + +
+ ) : null} +
+
+ {showTallySecretGenerator ? ( + + ) : null} + {showTallySecretGenerator && generatedSecret ? ( + <> + + + + ) : null}