diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6359fc72..25030852 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,13 +41,9 @@ jobs: echo "KIMAI_BASE_URL=${{ secrets.KIMAI_BASE_URL }}" >> "$GITHUB_ENV" echo "KIMAI_API_TOKEN=${{ secrets.KIMAI_API_TOKEN }}" >> "$GITHUB_ENV" - - name: Run tests with pytest - run: | - uv run pytest tests/ -m "not playwright" -v --tb=short - - name: Run tests with coverage run: | - uv run coverage run -m pytest tests/ -m "not playwright" + uv run coverage run -m pytest tests/ -m "not playwright" -v --tb=short uv run coverage report uv run coverage xml diff --git a/apps/admin_dashboard/src/components/empty.tsx b/apps/admin_dashboard/src/components/empty.tsx new file mode 100644 index 00000000..10f7dfbb --- /dev/null +++ b/apps/admin_dashboard/src/components/empty.tsx @@ -0,0 +1,11 @@ +type EmptyProps = { + children: string + hidden: boolean +} + +function Empty({ children, hidden }: EmptyProps) { + if (hidden) return null + return
{children}
+} + +export { Empty } diff --git a/apps/admin_dashboard/src/components/sortable-table-head.tsx b/apps/admin_dashboard/src/components/sortable-table-head.tsx new file mode 100644 index 00000000..74018273 --- /dev/null +++ b/apps/admin_dashboard/src/components/sortable-table-head.tsx @@ -0,0 +1,50 @@ +import { TableHead } from "@/components/ui/table" + +type SortDirection = "asc" | "desc" + +type SortButtonProps = { + label: string + scope: string + sort: { key: string; direction: SortDirection } + sortKey: string + onSort: (scope: string, key: string) => void +} + +function SortButton({ label, scope, sort, sortKey, onSort }: SortButtonProps) { + const active = sort.key === sortKey + const arrow = sort.direction === "asc" ? "↑" : "↓" + return ( + + ) +} + +type SortableTableHeadProps = SortButtonProps & { + className?: string +} + +function SortableTableHead({ + className, + label, + scope, + sort, + sortKey, + onSort, +}: SortableTableHeadProps) { + const ariaSort = + sort.key === sortKey ? (sort.direction === "asc" ? "ascending" : "descending") : "none" + return ( + + + + ) +} + +export { SortableTableHead } diff --git a/apps/admin_dashboard/src/configuration-view.test.tsx b/apps/admin_dashboard/src/configuration-view.test.tsx index b5f1e33e..87d6ee53 100644 --- a/apps/admin_dashboard/src/configuration-view.test.tsx +++ b/apps/admin_dashboard/src/configuration-view.test.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react" import { afterEach, describe, expect, it, vi } from "vitest" -import { type ConfigurationItem, ConfigurationView } from "./main" +import { type ConfigurationItem, ConfigurationView } from "./views/configuration-view" afterEach(() => cleanup()) diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index a6e57f44..3406ec8f 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -22,7 +22,8 @@ import { } from "lucide-react" import { type ReactNode, StrictMode, useEffect, useMemo, useRef, useState } from "react" import { createRoot } from "react-dom/client" - +import { Empty } from "@/components/empty" +import { SortableTableHead } from "@/components/sortable-table-head" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -50,6 +51,19 @@ import { toneForOnboardingState, } from "@/dashboard-utils" import { cn } from "@/lib/utils" +import { + type ConfigurationItem, + type ConfigurationResponse, + ConfigurationView, +} from "@/views/configuration-view" +import { + type NewsletterStatus, + type NewsletterSuppression, + type NewsletterSyncPreview, + newsletterPreviewSummary, + newsletterProviderResults, +} from "@/views/newsletter-models" +import { NewsletterView } from "@/views/newsletter-view" import "./index.css" type View = @@ -74,105 +88,6 @@ type User = { permissions?: string[] } -type ConfigurationItem = { - key: string - label: string - category: string - description: string - value_type: "string" | "bool" | "int" | "float" | "url" | "csv" - is_secret: boolean - env_locked: boolean - source: "env" | "database" | "default" - configured: boolean - restart_required: boolean - secret_encryption_configured?: boolean | null - value?: string | number | boolean | null - masked_value?: string | null -} - -type ConfigurationResponse = { - items: ConfigurationItem[] -} - -type ConfigurationGroupMetadata = { - category: string - label: string - description: string -} - -const configurationGroups: ConfigurationGroupMetadata[] = [ - { - category: "CRM", - label: "CRM", - description: "EspoCRM connection settings used by the API, worker, and Discord bot.", - }, - { - category: "Projects", - label: "Projects", - description: "ERPNext credentials and project workflow settings.", - }, - { - category: "Onboarding", - label: "Onboarding", - description: - "Editable onboarding integrations such as DocuSeal, Outline, and onboarding email SMTP.", - }, - { - category: "Newsletter", - label: "Newsletter", - description: "Brevo, Keila, and recurring 508 members audience sync settings.", - }, - { - category: "AI", - label: "AI Providers", - description: "Provider credentials, base URLs, and model defaults.", - }, - { - category: "Agent", - label: "Agent Runtime", - description: "Planner, fallback, and tiered model routing for agent workflows.", - }, - { - category: "Observability", - label: "Observability", - description: "Telemetry and request tracing integrations.", - }, - { - category: "Intake", - label: "Intake", - description: "Resume and mailbox intake limits and parser defaults.", - }, - { - category: "Operations", - label: "Operations", - description: "Queue, sync, GitHub, and notification behavior.", - }, - { - category: "Legacy", - label: "Legacy", - description: "Older integrations retained for compatibility.", - }, -] - -const configurationGroupByCategory = new Map( - configurationGroups.map((group, index) => [group.category, { ...group, index }]), -) - -function configurationGroupId(category: string) { - return `configurationGroup-${category.replace(/[^a-zA-Z0-9_-]+/g, "-")}` -} - -function isPrimaryConfiguration(item: ConfigurationItem) { - return ( - item.key.startsWith("ONBOARDING_EMAIL_") || - item.is_secret || - item.value_type === "url" || - item.key.endsWith("_MODEL") || - item.key.endsWith("_API_USER") || - item.key.endsWith("_BASE_URL") - ) -} - type ProfileStatus = { crm_active?: boolean is_member?: boolean @@ -441,47 +356,6 @@ type WikiMatchPreview = { }> } -type NewsletterSyncPreview = { - mailboxes_scanned?: number - contacts_considered?: number - providers?: Record< - string, - { - would_sync?: number - synced?: number - skipped?: number - failed?: number - } - > -} - -type NewsletterSuppression = { - email: string - source_provider: string - reason: string - active?: boolean - first_seen_at?: string - last_seen_at?: string - updated_at?: string - metadata?: Record -} - -type NewsletterStatus = { - scheduler_enabled?: boolean - interval_seconds?: number - active_suppression_count?: number - active_suppressed_email_count?: number - latest_job?: JobDetail | null -} - -type NewsletterProviderResult = { - synced?: number - would_sync?: number - skipped?: number - failed?: number - statuses?: Record -} - type AuditEvent = { id?: string occurred_at?: string @@ -702,52 +576,6 @@ function messageFromUnknown(error: unknown, fallback: string) { return fallback } -function newsletterPreviewSummary(preview: NewsletterSyncPreview | undefined) { - if (!preview) return null - const providerSummaries = Object.entries(preview.providers || {}).map(([name, result]) => { - const wouldSync = result.would_sync ?? result.synced ?? 0 - return `${name}: ${wouldSync} would sync, ${result.skipped || 0} skipped, ${result.failed || 0} failed` - }) - const scanned = preview.mailboxes_scanned ?? 0 - const contacts = preview.contacts_considered ?? 0 - return [ - `${scanned} mailbox${scanned === 1 ? "" : "es"}`, - `${contacts} contact${contacts === 1 ? "" : "s"}`, - ...providerSummaries, - ].join("; ") -} - -function newsletterIntervalLabel(seconds?: number) { - const normalized = Number(seconds || 0) - if (!Number.isFinite(normalized) || normalized <= 0) return "Not configured" - if (normalized % 86400 === 0) { - const days = normalized / 86400 - return `${days} day${days === 1 ? "" : "s"}` - } - if (normalized % 3600 === 0) { - const hours = normalized / 3600 - return `${hours} hour${hours === 1 ? "" : "s"}` - } - if (normalized % 60 === 0) { - const minutes = normalized / 60 - return `${minutes} minute${minutes === 1 ? "" : "s"}` - } - return `${normalized} second${normalized === 1 ? "" : "s"}` -} - -function newsletterProviderResults(job?: JobDetail | null) { - const result = job?.result - if (!result || typeof result !== "object" || Array.isArray(result)) return [] - const providers = (result as { providers?: unknown }).providers - if (!providers || typeof providers !== "object" || Array.isArray(providers)) return [] - return Object.entries(providers) - .filter((entry): entry is [string, NewsletterProviderResult] => { - const [, value] = entry - return Boolean(value && typeof value === "object" && !Array.isArray(value)) - }) - .sort(([left], [right]) => left.localeCompare(right)) -} - function devErrorFromUnknown(error: unknown, fallback: string): DashboardDevError { const message = messageFromUnknown(error, fallback) const apiError = error instanceof ApiRequestError ? error : null @@ -929,58 +757,6 @@ function sortItems( }) } -function SortButton({ - label, - scope, - sort, - sortKey, - onSort, -}: { - label: string - scope: View - sort: { key: string; direction: SortDirection } - sortKey: string - onSort: (scope: View, key: string) => void -}) { - const active = sort.key === sortKey - const arrow = sort.direction === "asc" ? "↑" : "↓" - return ( - - ) -} - -function SortableTableHead({ - className, - label, - scope, - sort, - sortKey, - onSort, -}: { - className?: string - label: string - scope: View - sort: { key: string; direction: SortDirection } - sortKey: string - onSort: (scope: View, key: string) => void -}) { - const ariaSort = - sort.key === sortKey ? (sort.direction === "asc" ? "ascending" : "descending") : "none" - return ( - - - - ) -} - function Metric({ label, value, id }: { label: string; value: number; id?: string }) { return ( @@ -992,11 +768,6 @@ function Metric({ label, value, id }: { label: string; value: number; id?: strin ) } -function Empty({ children, hidden }: { children: string; hidden: boolean }) { - if (hidden) return null - return
{children}
-} - function HighlightedText({ value, query }: { value: string; query: string }) { const normalizedQuery = query.trim().toLowerCase() if (!normalizedQuery) return <>{value} @@ -5982,283 +5753,6 @@ function PeopleView(props: { ) } -function NewsletterView(props: { - status: NewsletterStatus | null - suppressions: NewsletterSuppression[] - providerOptions: string[] - providerFilter: string - sort: { key: string; direction: SortDirection } - loading: Record - canSync: boolean - onRefresh: () => void - onSync: () => void - onProviderFilterChange: (value: string) => void - onSort: (key: string) => void -}) { - const latestJob = props.status?.latest_job || null - const uniqueSuppressed = props.status?.active_suppressed_email_count - const suppressionRows = props.status?.active_suppression_count - const providerResults = newsletterProviderResults(latestJob) - const providerOptions = props.providerFilter - ? [...new Set([...props.providerOptions, props.providerFilter])].sort((left, right) => - left.localeCompare(right), - ) - : props.providerOptions - const suppressionsByProvider = props.suppressions.reduce>( - (groups, record) => { - const provider = record.source_provider || "unknown" - groups[provider] = [...(groups[provider] || []), record] - return groups - }, - {}, - ) - const suppressionGroups = Object.entries(suppressionsByProvider).sort(([left], [right]) => - left.localeCompare(right), - ) - return ( - <> - - - Newsletter sync -
- - {props.canSync ? ( - - ) : null} -
-
- -
-
- Scheduler - - {props.status?.scheduler_enabled ? "Enabled" : "Disabled"} - -
-
- Interval - - {newsletterIntervalLabel(props.status?.interval_seconds)} - -
-
- Suppressed emails - - {uniqueSuppressed ?? "Loading"} - -
-
- Suppression rows - - {suppressionRows ?? "Loading"} - -
-
-
-
- Latest sync - {latestJob ? ( - {latestJob.status} - ) : ( - No job found - )} -
- {latestJob ? ( - <> -
- {latestJob.type} | updated {formatDate(latestJob.updated_at)} | attempts{" "} - {latestJob.attempts}/{latestJob.max_attempts} -
- {latestJob.last_error ? ( -
{latestJob.last_error}
- ) : null} -
- {providerResults.length ? ( - providerResults.map(([providerName, providerResult]) => { - const statuses = providerResult.statuses || {} - const statusEntries = Object.entries(statuses).sort(([left], [right]) => - left.localeCompare(right), - ) - const synced = providerResult.synced ?? providerResult.would_sync ?? 0 - return ( -
-
- {providerName} -
- {synced} synced - {providerResult.skipped || 0} skipped - {providerResult.failed ? ( - {providerResult.failed} failed - ) : null} -
-
- {statusEntries.length ? ( -
- {statusEntries.map(([status, count]) => ( - - {status.replaceAll("_", " ")}: {count} - - ))} -
- ) : null} -
- ) - }) - ) : ( -
- No provider status details recorded for the latest sync. -
- )} -
- {latestJob.result ? ( -
-                    {jsonPreview(latestJob.result)}
-                  
- ) : null} - - ) : ( -
- No 508 members newsletter sync job was found in the last 90 days. -
- )} -
-
-
- - - - Newsletter suppressions -
- - - {props.loading.newsletterSuppressions - ? "Loading" - : `${props.suppressions.length} shown`} - -
-
- -
- {suppressionGroups.map(([provider, records]) => ( -
-
- {provider} - - {records.length} suppression{records.length === 1 ? "" : "s"} - -
-
- - - - props.onSort(key)} - /> - props.onSort(key)} - /> - props.onSort(key)} - /> - props.onSort(key)} - /> - props.onSort(key)} - /> - - - - {records.map((record) => ( - - {record.email} - - {record.source_provider} - - {record.reason.replaceAll("_", " ")} - {formatDate(record.first_seen_at)} - - {formatDate(record.last_seen_at || record.updated_at)} - - - ))} - -
-
-
- ))} -
-
- - ) -} - function OnboardingView(props: { people: Person[] sort: { key: string; direction: SortDirection } @@ -7723,471 +7217,6 @@ function AgentView({ ) } -function ConfigurationView({ - items, - loading, - canWrite, - onRefresh, - onSave, - onClear, - focusCategory, - focusNonce, -}: { - items: ConfigurationItem[] - loading: Record - canWrite: boolean - onRefresh: () => void - onSave: (key: string, value: string) => Promise - onClear: (key: string) => void - focusCategory?: string - focusNonce?: number -}) { - 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)) - const unknown = Array.from(present) - .filter((category) => !configurationGroupByCategory.has(category)) - .sort() - .map((category) => ({ - category, - label: category, - description: "Additional runtime settings.", - })) - return known.concat(unknown) - }, [items]) - const groupedItems = useMemo(() => { - const groups = new Map() - for (const item of items) { - if (selectedCategory !== "All" && item.category !== selectedCategory) continue - const current = groups.get(item.category) ?? [] - current.push(item) - groups.set(item.category, current) - } - return Array.from(groups.entries()) - .map(([category, groupItems]) => { - const known = configurationGroupByCategory.get(category) - const sortedItems = groupItems.sort((left, right) => { - const priorityOrder = - Number(!isPrimaryConfiguration(left)) - Number(!isPrimaryConfiguration(right)) - return priorityOrder || left.label.localeCompare(right.label) - }) - const primaryItems = sortedItems.filter(isPrimaryConfiguration) - const advancedItems = sortedItems.filter((item) => !isPrimaryConfiguration(item)) - return { - category, - label: known?.label ?? category, - description: known?.description ?? "Additional runtime settings.", - order: known?.index ?? configurationGroups.length, - primaryItems: primaryItems.length ? primaryItems : sortedItems, - advancedItems: primaryItems.length ? advancedItems : [], - items: sortedItems, - } - }) - .sort((left, right) => left.order - right.order || left.label.localeCompare(right.label)) - }, [items, selectedCategory]) - const summary = useMemo( - () => ({ - configured: items.filter((item) => item.configured).length, - envLocked: items.filter((item) => item.env_locked).length, - missing: items.filter((item) => !item.configured).length, - }), - [items], - ) - const visibleItemCount = groupedItems.reduce((count, group) => count + group.items.length, 0) - - useEffect(() => { - setDrafts( - Object.fromEntries( - items.map((item) => [item.key, item.is_secret ? "" : String(item.value ?? "")]), - ), - ) - }, [items]) - - useEffect(() => { - if (selectedCategory !== "All" && !items.some((item) => item.category === selectedCategory)) { - setSelectedCategory("All") - } - }, [items, selectedCategory]) - - useEffect(() => { - if (!focusCategory || !items.some((item) => item.category === focusCategory)) return - void focusNonce - setSelectedCategory(focusCategory) - setHighlightedCategory(focusCategory) - const frame = window.requestAnimationFrame?.(() => { - const element = document.getElementById(configurationGroupId(focusCategory)) - if (typeof element?.scrollIntoView === "function") { - element.scrollIntoView({ - block: "start", - behavior: "smooth", - }) - } - }) - const timeout = window.setTimeout(() => setHighlightedCategory(""), 4000) - return () => { - if (frame !== undefined) window.cancelAnimationFrame?.(frame) - window.clearTimeout(timeout) - } - }, [focusCategory, focusNonce, items]) - - function sourceBadge(item: ConfigurationItem) { - if (item.source === "env") return "ENV" - if (item.source === "database") return "DB" - 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) { - try { - await navigator.clipboard.writeText(secret) - return - } catch { - // Fall through to selecting the visible field. - } - } - 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}`] - if (item.value_type === "bool") { - return ( - - ) - } - return ( - - setDrafts((current) => ({ ...current, [item.key]: event.target.value })) - } - /> - ) - } - - function configurationTable(groupLabel: string, tableItems: ConfigurationItem[], suffix: string) { - return ( -
- - - - Setting - Source - Active - Value - Actions - - - - {tableItems.map((item) => renderConfigurationRow(item))} - -
-
- ) - } - - function renderConfigurationRow(item: ConfigurationItem) { - const busy = loading[`configuration:${item.key}`] - 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 ( - - -
- {item.label} - {item.key} - {item.description} - {item.restart_required ? ( -
- Restart -
- ) : null} -
-
- -
- - {sourceBadge(item)} - - {item.env_locked ? ( - Environment locked - ) : null} -
-
- -
- - {item.configured ? "Configured" : "Missing"} - - {item.is_secret ? ( - - {item.masked_value || (item.configured ? "Hidden" : "No secret")} - - ) : ( - - {String(item.value ?? "") || "Default"} - - )} - {item.is_secret && item.secret_encryption_configured === false ? ( - Encryption key missing - ) : null} -
-
- -
- {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} - - -
-
-
- ) - } - - return ( -
- - - Configuration - - - -
- {[ - ["Total", items.length], - ["Configured", summary.configured], - ["Missing", summary.missing], - ["Env locked", summary.envLocked], - ].map(([label, value]) => ( -
- - {label} - - {value} -
- ))} -
-
- - {categories.map((group) => { - const count = items.filter((item) => item.category === group.category).length - return ( - - ) - })} -
-
-
- - - {groupedItems.map((group) => { - const configured = group.items.filter((item) => item.configured).length - const missing = group.items.length - configured - const restartRequired = group.items.some((item) => item.restart_required) - return ( - - -
- {group.label} - {group.description} -
-
- {group.items.length} settings - {configured} configured - {restartRequired ? Restart : null} -
-
- {configurationTable(group.label, group.primaryItems, group.category)} - {group.advancedItems.length ? ( -
- - Advanced - - {group.advancedItems.length} settings - - -
- {configurationTable( - `${group.label} advanced`, - group.advancedItems, - `${group.category}-advanced`, - )} -
-
- ) : null} -
- ) - })} -
- ) -} - const root = document.getElementById("root") if (!root) { @@ -8202,5 +7231,5 @@ if (!root) { ) } -export type { ConfigurationItem } -export { ConfigurationView } +export type { ConfigurationItem } from "@/views/configuration-view" +export { ConfigurationView } from "@/views/configuration-view" diff --git a/apps/admin_dashboard/src/views/configuration-view.tsx b/apps/admin_dashboard/src/views/configuration-view.tsx new file mode 100644 index 00000000..36ced038 --- /dev/null +++ b/apps/admin_dashboard/src/views/configuration-view.tsx @@ -0,0 +1,585 @@ +import { RefreshCw } from "lucide-react" +import { useEffect, useMemo, useState } from "react" + +import { Empty } from "@/components/empty" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Select } from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { cn } from "@/lib/utils" + +type ConfigurationItem = { + key: string + label: string + category: string + description: string + value_type: "string" | "bool" | "int" | "float" | "url" | "csv" + is_secret: boolean + env_locked: boolean + source: "env" | "database" | "default" + configured: boolean + restart_required: boolean + secret_encryption_configured?: boolean | null + value?: string | number | boolean | null + masked_value?: string | null +} + +type ConfigurationResponse = { + items: ConfigurationItem[] +} + +type ConfigurationGroupMetadata = { + category: string + label: string + description: string +} + +const configurationGroups: ConfigurationGroupMetadata[] = [ + { + category: "CRM", + label: "CRM", + description: "EspoCRM connection settings used by the API, worker, and Discord bot.", + }, + { + category: "Projects", + label: "Projects", + description: "ERPNext credentials and project workflow settings.", + }, + { + category: "Onboarding", + label: "Onboarding", + description: + "Editable onboarding integrations such as DocuSeal, Outline, and onboarding email SMTP.", + }, + { + category: "Newsletter", + label: "Newsletter", + description: "Brevo, Keila, and recurring 508 members audience sync settings.", + }, + { + category: "AI", + label: "AI Providers", + description: "Provider credentials, base URLs, and model defaults.", + }, + { + category: "Agent", + label: "Agent Runtime", + description: "Planner, fallback, and tiered model routing for agent workflows.", + }, + { + category: "Observability", + label: "Observability", + description: "Telemetry and request tracing integrations.", + }, + { + category: "Intake", + label: "Intake", + description: "Resume and mailbox intake limits and parser defaults.", + }, + { + category: "Operations", + label: "Operations", + description: "Queue, sync, GitHub, and notification behavior.", + }, + { + category: "Legacy", + label: "Legacy", + description: "Older integrations retained for compatibility.", + }, +] + +const configurationGroupByCategory = new Map( + configurationGroups.map((group, index) => [group.category, { ...group, index }]), +) + +function configurationGroupId(category: string) { + return `configurationGroup-${category.replace(/[^a-zA-Z0-9_-]+/g, "-")}` +} + +function isPrimaryConfiguration(item: ConfigurationItem) { + return ( + item.key.startsWith("ONBOARDING_EMAIL_") || + item.is_secret || + item.value_type === "url" || + item.key.endsWith("_MODEL") || + item.key.endsWith("_API_USER") || + item.key.endsWith("_BASE_URL") + ) +} + +function ConfigurationView({ + items, + loading, + canWrite, + onRefresh, + onSave, + onClear, + focusCategory, + focusNonce, +}: { + items: ConfigurationItem[] + loading: Record + canWrite: boolean + onRefresh: () => void + onSave: (key: string, value: string) => Promise + onClear: (key: string) => void + focusCategory?: string + focusNonce?: number +}) { + 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)) + const unknown = Array.from(present) + .filter((category) => !configurationGroupByCategory.has(category)) + .sort() + .map((category) => ({ + category, + label: category, + description: "Additional runtime settings.", + })) + return known.concat(unknown) + }, [items]) + const groupedItems = useMemo(() => { + const groups = new Map() + for (const item of items) { + if (selectedCategory !== "All" && item.category !== selectedCategory) continue + const current = groups.get(item.category) ?? [] + current.push(item) + groups.set(item.category, current) + } + return Array.from(groups.entries()) + .map(([category, groupItems]) => { + const known = configurationGroupByCategory.get(category) + const sortedItems = groupItems.sort((left, right) => { + const priorityOrder = + Number(!isPrimaryConfiguration(left)) - Number(!isPrimaryConfiguration(right)) + return priorityOrder || left.label.localeCompare(right.label) + }) + const primaryItems = sortedItems.filter(isPrimaryConfiguration) + const advancedItems = sortedItems.filter((item) => !isPrimaryConfiguration(item)) + return { + category, + label: known?.label ?? category, + description: known?.description ?? "Additional runtime settings.", + order: known?.index ?? configurationGroups.length, + primaryItems: primaryItems.length ? primaryItems : sortedItems, + advancedItems: primaryItems.length ? advancedItems : [], + items: sortedItems, + } + }) + .sort((left, right) => left.order - right.order || left.label.localeCompare(right.label)) + }, [items, selectedCategory]) + const summary = useMemo( + () => ({ + configured: items.filter((item) => item.configured).length, + envLocked: items.filter((item) => item.env_locked).length, + missing: items.filter((item) => !item.configured).length, + }), + [items], + ) + const visibleItemCount = groupedItems.reduce((count, group) => count + group.items.length, 0) + + useEffect(() => { + setDrafts( + Object.fromEntries( + items.map((item) => [item.key, item.is_secret ? "" : String(item.value ?? "")]), + ), + ) + }, [items]) + + useEffect(() => { + if (selectedCategory !== "All" && !items.some((item) => item.category === selectedCategory)) { + setSelectedCategory("All") + } + }, [items, selectedCategory]) + + useEffect(() => { + if (!focusCategory || !items.some((item) => item.category === focusCategory)) return + void focusNonce + setSelectedCategory(focusCategory) + setHighlightedCategory(focusCategory) + const frame = window.requestAnimationFrame?.(() => { + const element = document.getElementById(configurationGroupId(focusCategory)) + if (typeof element?.scrollIntoView === "function") { + element.scrollIntoView({ + block: "start", + behavior: "smooth", + }) + } + }) + const timeout = window.setTimeout(() => setHighlightedCategory(""), 4000) + return () => { + if (frame !== undefined) window.cancelAnimationFrame?.(frame) + window.clearTimeout(timeout) + } + }, [focusCategory, focusNonce, items]) + + function sourceBadge(item: ConfigurationItem) { + if (item.source === "env") return "ENV" + if (item.source === "database") return "DB" + 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) { + try { + await navigator.clipboard.writeText(secret) + return + } catch { + // Fall through to selecting the visible field. + } + } + 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}`] + if (item.value_type === "bool") { + return ( + + ) + } + return ( + + setDrafts((current) => ({ ...current, [item.key]: event.target.value })) + } + /> + ) + } + + function configurationTable(groupLabel: string, tableItems: ConfigurationItem[], suffix: string) { + return ( +
+ + + + Setting + Source + Active + Value + Actions + + + + {tableItems.map((item) => renderConfigurationRow(item))} + +
+
+ ) + } + + function renderConfigurationRow(item: ConfigurationItem) { + const busy = loading[`configuration:${item.key}`] + 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 ( + + +
+ {item.label} + {item.key} + {item.description} + {item.restart_required ? ( +
+ Restart +
+ ) : null} +
+
+ +
+ + {sourceBadge(item)} + + {item.env_locked ? ( + Environment locked + ) : null} +
+
+ +
+ + {item.configured ? "Configured" : "Missing"} + + {item.is_secret ? ( + + {item.masked_value || (item.configured ? "Hidden" : "No secret")} + + ) : ( + + {String(item.value ?? "") || "Default"} + + )} + {item.is_secret && item.secret_encryption_configured === false ? ( + Encryption key missing + ) : null} +
+
+ +
+ {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} + + +
+
+
+ ) + } + + return ( +
+ + + Configuration + + + +
+ {[ + ["Total", items.length], + ["Configured", summary.configured], + ["Missing", summary.missing], + ["Env locked", summary.envLocked], + ].map(([label, value]) => ( +
+ + {label} + + {value} +
+ ))} +
+
+ + {categories.map((group) => { + const count = items.filter((item) => item.category === group.category).length + return ( + + ) + })} +
+
+
+ + + {groupedItems.map((group) => { + const configured = group.items.filter((item) => item.configured).length + const missing = group.items.length - configured + const restartRequired = group.items.some((item) => item.restart_required) + return ( + + +
+ {group.label} + {group.description} +
+
+ {group.items.length} settings + {configured} configured + {restartRequired ? Restart : null} +
+
+ {configurationTable(group.label, group.primaryItems, group.category)} + {group.advancedItems.length ? ( +
+ + Advanced + + {group.advancedItems.length} settings + + +
+ {configurationTable( + `${group.label} advanced`, + group.advancedItems, + `${group.category}-advanced`, + )} +
+
+ ) : null} +
+ ) + })} +
+ ) +} + +export type { ConfigurationItem, ConfigurationResponse } +export { ConfigurationView } diff --git a/apps/admin_dashboard/src/views/newsletter-models.ts b/apps/admin_dashboard/src/views/newsletter-models.ts new file mode 100644 index 00000000..17ba598b --- /dev/null +++ b/apps/admin_dashboard/src/views/newsletter-models.ts @@ -0,0 +1,106 @@ +type NewsletterJobDetail = { + type: string + status: string + attempts: number + max_attempts: number + updated_at?: string + last_error?: string | null + result?: unknown +} + +type NewsletterSyncPreview = { + mailboxes_scanned?: number + contacts_considered?: number + providers?: Record< + string, + { + would_sync?: number + synced?: number + skipped?: number + failed?: number + } + > +} + +type NewsletterSuppression = { + email: string + source_provider: string + reason: string + active?: boolean + first_seen_at?: string + last_seen_at?: string + updated_at?: string + metadata?: Record +} + +type NewsletterStatus = { + scheduler_enabled?: boolean + interval_seconds?: number + active_suppression_count?: number + active_suppressed_email_count?: number + latest_job?: NewsletterJobDetail | null +} + +type NewsletterProviderResult = { + synced?: number + would_sync?: number + skipped?: number + failed?: number + statuses?: Record +} + +function newsletterPreviewSummary(preview: NewsletterSyncPreview | undefined) { + if (!preview) return null + const providerSummaries = Object.entries(preview.providers || {}).map(([name, result]) => { + const wouldSync = result.would_sync ?? result.synced ?? 0 + return `${name}: ${wouldSync} would sync, ${result.skipped || 0} skipped, ${result.failed || 0} failed` + }) + const scanned = preview.mailboxes_scanned ?? 0 + const contacts = preview.contacts_considered ?? 0 + return [ + `${scanned} mailbox${scanned === 1 ? "" : "es"}`, + `${contacts} contact${contacts === 1 ? "" : "s"}`, + ...providerSummaries, + ].join("; ") +} + +function newsletterIntervalLabel(seconds?: number) { + const normalized = Number(seconds || 0) + if (!Number.isFinite(normalized) || normalized <= 0) return "Not configured" + if (normalized % 86400 === 0) { + const days = normalized / 86400 + return `${days} day${days === 1 ? "" : "s"}` + } + if (normalized % 3600 === 0) { + const hours = normalized / 3600 + return `${hours} hour${hours === 1 ? "" : "s"}` + } + if (normalized % 60 === 0) { + const minutes = normalized / 60 + return `${minutes} minute${minutes === 1 ? "" : "s"}` + } + return `${normalized} second${normalized === 1 ? "" : "s"}` +} + +function newsletterProviderResults(job?: NewsletterJobDetail | null) { + const result = job?.result + if (!result || typeof result !== "object" || Array.isArray(result)) return [] + const providers = (result as { providers?: unknown }).providers + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return [] + return Object.entries(providers) + .filter((entry): entry is [string, NewsletterProviderResult] => { + const [, value] = entry + return Boolean(value && typeof value === "object" && !Array.isArray(value)) + }) + .sort(([left], [right]) => left.localeCompare(right)) +} + +export type { + NewsletterJobDetail, + NewsletterProviderResult, + NewsletterStatus, + NewsletterSuppression, + NewsletterSyncPreview, +} + +export { newsletterIntervalLabel, newsletterPreviewSummary, newsletterProviderResults } diff --git a/apps/admin_dashboard/src/views/newsletter-view.tsx b/apps/admin_dashboard/src/views/newsletter-view.tsx new file mode 100644 index 00000000..1987b8fc --- /dev/null +++ b/apps/admin_dashboard/src/views/newsletter-view.tsx @@ -0,0 +1,299 @@ +import { RefreshCw } from "lucide-react" + +import { Empty } from "@/components/empty" +import { SortableTableHead } from "@/components/sortable-table-head" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Select } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table" +import { formatDate, jsonPreview, type Tone } from "@/dashboard-utils" +import { cn } from "@/lib/utils" +import { + type NewsletterStatus, + type NewsletterSuppression, + newsletterIntervalLabel, + newsletterProviderResults, +} from "@/views/newsletter-models" + +type SortDirection = "asc" | "desc" + +function NewsletterView(props: { + status: NewsletterStatus | null + suppressions: NewsletterSuppression[] + providerOptions: string[] + providerFilter: string + sort: { key: string; direction: SortDirection } + loading: Record + canSync: boolean + onRefresh: () => void + onSync: () => void + onProviderFilterChange: (value: string) => void + onSort: (key: string) => void +}) { + const latestJob = props.status?.latest_job || null + const uniqueSuppressed = props.status?.active_suppressed_email_count + const suppressionRows = props.status?.active_suppression_count + const providerResults = newsletterProviderResults(latestJob) + const providerOptions = props.providerFilter + ? [...new Set([...props.providerOptions, props.providerFilter])].sort((left, right) => + left.localeCompare(right), + ) + : props.providerOptions + const suppressionsByProvider = props.suppressions.reduce>( + (groups, record) => { + const provider = record.source_provider || "unknown" + groups[provider] = [...(groups[provider] || []), record] + return groups + }, + {}, + ) + const suppressionGroups = Object.entries(suppressionsByProvider).sort(([left], [right]) => + left.localeCompare(right), + ) + return ( + <> + + + Newsletter sync +
+ + {props.canSync ? ( + + ) : null} +
+
+ +
+
+ Scheduler + + {props.status?.scheduler_enabled ? "Enabled" : "Disabled"} + +
+
+ Interval + + {newsletterIntervalLabel(props.status?.interval_seconds)} + +
+
+ Suppressed emails + + {uniqueSuppressed ?? "Loading"} + +
+
+ Suppression rows + + {suppressionRows ?? "Loading"} + +
+
+
+
+ Latest sync + {latestJob ? ( + {latestJob.status} + ) : ( + No job found + )} +
+ {latestJob ? ( + <> +
+ {latestJob.type} | updated {formatDate(latestJob.updated_at)} | attempts{" "} + {latestJob.attempts}/{latestJob.max_attempts} +
+ {latestJob.last_error ? ( +
{latestJob.last_error}
+ ) : null} +
+ {providerResults.length ? ( + providerResults.map(([providerName, providerResult]) => { + const statuses = providerResult.statuses || {} + const statusEntries = Object.entries(statuses).sort(([left], [right]) => + left.localeCompare(right), + ) + const synced = providerResult.synced ?? providerResult.would_sync ?? 0 + return ( +
+
+ {providerName} +
+ {synced} synced + {providerResult.skipped || 0} skipped + {providerResult.failed ? ( + {providerResult.failed} failed + ) : null} +
+
+ {statusEntries.length ? ( +
+ {statusEntries.map(([status, count]) => ( + + {status.replaceAll("_", " ")}: {count} + + ))} +
+ ) : null} +
+ ) + }) + ) : ( +
+ No provider status details recorded for the latest sync. +
+ )} +
+ {latestJob.result ? ( +
+                    {jsonPreview(latestJob.result)}
+                  
+ ) : null} + + ) : ( +
+ No 508 members newsletter sync job was found in the last 90 days. +
+ )} +
+
+
+ + + + Newsletter suppressions +
+ + + {props.loading.newsletterSuppressions + ? "Loading" + : `${props.suppressions.length} shown`} + +
+
+ +
+ {suppressionGroups.map(([provider, records]) => ( +
+
+ {provider} + + {records.length} suppression{records.length === 1 ? "" : "s"} + +
+
+ + + + props.onSort(key)} + /> + props.onSort(key)} + /> + props.onSort(key)} + /> + props.onSort(key)} + /> + props.onSort(key)} + /> + + + + {records.map((record) => ( + + {record.email} + + {record.source_provider} + + {record.reason.replaceAll("_", " ")} + {formatDate(record.first_seen_at)} + + {formatDate(record.last_seen_at || record.updated_at)} + + + ))} + +
+
+
+ ))} +
+
+ + ) +} + +export { NewsletterView } diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index fa0ec938..9d6702cc 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -12,6 +12,7 @@ import re import secrets import smtplib +import sys import json import threading import time @@ -28,7 +29,7 @@ from fastapi import FastAPI, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field, ValidationError +from pydantic import ValidationError from psycopg import Connection from psycopg.rows import dict_row @@ -107,8 +108,32 @@ normalize_next_path, ) from five08.clients.erpnext import ERPNextAPIError, ERPNextClient +from five08.backend.routes import BackendRouteSurface, register_routes +from five08.backend.schemas import ( + AgentConfirmationRequest, + DashboardAssignOnboarderRequest, + DashboardBulkProjectUpdateRequest, + DashboardConfigurationUpdateRequest, + DashboardEngineerSetupRequest, + DashboardGigApplicationCreateRequest, + DashboardGigApplicationStatusRequest, + DashboardGigStatusRequest, + DashboardOnboardingEmailDraftRequest, + DashboardOnboardingEmailSendRequest, + DashboardOnboardingStatusRequest, + DashboardProjectCreateRequest, + DashboardProjectHistoricalMemberRemoveRequest, + DashboardProjectHistoricalMemberRequest, + DashboardProjectStatusRequest, + DashboardProjectUserRemoveRequest, + DashboardProjectUserRequest, + DashboardProjectWikiMatchRequest, + DiscordLinkCreateRequest, + ResumeApplyRequest, + ResumeExtractRequest, +) from five08.backend.dashboard import ( - dashboard_assets_dir, + dashboard_assets_dir as dashboard_assets_dir, dashboard_html, discord_link_continue_html, discord_link_unavailable_html, @@ -198,185 +223,6 @@ _PROJECT_ROSTER_USER_CANDIDATE_CACHE: dict[str, tuple[float, list[dict[str, Any]]]] = {} -class ResumeExtractRequest(BaseModel): - """Request schema for queued resume extraction.""" - - contact_id: str - attachment_id: str - filename: str - refresh_token: str | None = None - - -class ResumeApplyRequest(BaseModel): - """Request schema for queued resume apply updates.""" - - contact_id: str - updates: dict[str, Any] - link_discord: dict[str, str] | None = None - - -class DiscordLinkCreateRequest(BaseModel): - """Payload for creating one-time admin deep links from Discord commands.""" - - discord_user_id: str - next_path: str | None = None - discord_display_name: str | None = None - discord_roles: list[str] = Field(default_factory=list) - - -class AgentConfirmationRequest(BaseModel): - """Payload for confirming or canceling a frozen agent plan.""" - - context: AgentIdentityContext - confirm: bool = True - - -class DashboardAssignOnboarderRequest(BaseModel): - """Payload for assigning an onboarder from the dashboard.""" - - onboarder: str - - -class DashboardOnboardingStatusRequest(BaseModel): - """Payload for updating one dashboard onboarding status.""" - - status: str - - -class DashboardOnboardingEmailDraftRequest(BaseModel): - """Payload for drafting one dashboard onboarding email.""" - - has_contributed: bool = False - discord_joined: Literal["yes", "no", "unknown"] = "unknown" - agreement_signed: Literal["yes", "no", "unknown"] = "unknown" - - -class DashboardOnboardingEmailSendRequest(BaseModel): - """Payload for sending one reviewed dashboard onboarding email.""" - - markdown_body: str - has_contributed: bool = False - discord_joined: Literal["yes", "no", "unknown"] = "unknown" - agreement_signed: Literal["yes", "no", "unknown"] = "unknown" - - -class DashboardGigStatusRequest(BaseModel): - """Payload for updating one dashboard gig status.""" - - status: str - - -class DashboardProjectStatusRequest(BaseModel): - """Payload for updating one ERPNext Project status.""" - - status: str - - -class DashboardBulkProjectUpdateRequest(BaseModel): - """Payload for bulk ERPNext Project field updates.""" - - project_ids: list[str] - status: str | None = None - project_type: str | None = None - - -class DashboardProjectUserRequest(BaseModel): - """Payload for adding one ERPNext User to a Project roster.""" - - user: str - candidate_id: str | None = None - activity_type: str | None = None - billing_rate: float | None = None - costing_rate: float | None = None - - -class DashboardEngineerSetupRequest(BaseModel): - """Payload for setting up one ERPNext engineer account.""" - - email: str - first_name: str - middle_name: str | None = None - last_name: str | None = None - country: str | None = None - gender: str | None = None - date_of_birth: str | None = None - date_of_joining: str | None = None - personal_email: str | None = None - prefered_email: str | None = None - - -class DashboardProjectUserRemoveRequest(BaseModel): - """Payload for removing one ERPNext User from a Project roster.""" - - user: str - - -class DashboardProjectHistoricalMemberRequest(BaseModel): - """Payload for adding one local historical Project roster member.""" - - person: str - candidate_id: str | None = None - - -class DashboardProjectHistoricalMemberRemoveRequest(BaseModel): - """Payload for removing one local historical Project roster member.""" - - source_user_id: str - - -class DashboardProjectWikiMatchRequest(BaseModel): - """Payload for saving a manual project-to-wiki match decision.""" - - status: str - row_key: str | None = None - - -class DashboardProjectCreateRequest(BaseModel): - """Payload for creating a Customer-backed ERPNext Project.""" - - project_name: str - customer_mode: Literal["new", "existing"] = "new" - customer_name: str | None = None - customer: str | None = None - account_manager: str | None = None - default_billing_currency: str | None = "USD" - default_cost_center: str | None = "Projects - 5" - activity_type: str | None = None - customer_details: str | None = None - customer_website: str | None = None - address_line1: str | None = None - address_line2: str | None = None - address_city: str | None = None - address_state: str | None = None - address_country: str | None = None - address_postal_code: str | None = None - contact: str | None = None - contact_first_name: str | None = None - contact_last_name: str | None = None - contact_email: str | None = None - contact_phone: str | None = None - contact_mobile: str | None = None - - -class DashboardGigApplicationStatusRequest(BaseModel): - """Payload for updating one dashboard gig candidate/application status.""" - - status: str - - -class DashboardGigApplicationCreateRequest(BaseModel): - """Payload for adding one CRM-verified gig candidate/application.""" - - crm_profile: str = Field(min_length=1, max_length=500) - - -class DashboardConfigurationUpdateRequest(BaseModel): - """Payload for updating one admin-managed configuration value.""" - - value: str | bool | int | float | None = None - clear: bool = False - - @dataclass(frozen=True) class JobsQueryFilters: """Normalized query filters for job-list endpoints.""" @@ -9195,310 +9041,7 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI: app.state.oidc_client = OIDCProviderClient(settings) app.state.discord_admin_verifier = DiscordAdminVerifier(settings) - app.add_api_route("/", health_handler, methods=["GET"]) - app.add_api_route("/health", health_handler, methods=["GET"]) - - app.add_api_route( - "/dashboard", - dashboard_handler, - methods=["GET"], - response_model=None, - ) - assets_dir = dashboard_assets_dir() - app.mount( - "/dashboard/assets", - _OptionalDirectoryStaticFiles(directory=assets_dir, check_dir=False), - name="dashboard-assets", - ) - app.add_api_route( - "/dashboard/{view}", - dashboard_handler, - methods=["GET"], - response_model=None, - ) - app.add_api_route("/dashboard/api/me", dashboard_me_handler, methods=["GET"]) - app.add_api_route("/dashboard/api/jobs", dashboard_jobs_handler, methods=["GET"]) - app.add_api_route( - "/dashboard/api/jobs/{job_id}", - dashboard_job_detail_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/jobs/{job_id}/rerun", - dashboard_rerun_job_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/people", - dashboard_people_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/gigs", - dashboard_gigs_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/gigs/{engagement_id}", - dashboard_gig_detail_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/notifications", - dashboard_notifications_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/projects", - dashboard_projects_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/project-member-candidates", - dashboard_project_member_candidates_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/projects/wiki-matches", - dashboard_project_wiki_matches_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/erpnext/customers", - dashboard_erpnext_customers_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/erpnext/contacts", - dashboard_erpnext_contacts_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/erpnext/account-managers", - dashboard_erpnext_account_managers_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/erpnext/cost-centers", - dashboard_erpnext_cost_centers_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/projects/create", - dashboard_create_project_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/projects/bulk", - dashboard_bulk_update_projects_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/projects/{project_id}/status", - dashboard_update_project_status_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/projects/{project_id}/users", - dashboard_add_project_user_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/projects/{project_id}/users/remove", - dashboard_remove_project_user_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/projects/{project_id}/historical-members", - dashboard_add_project_historical_member_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/projects/{project_id}/historical-members/remove", - dashboard_remove_project_historical_member_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/projects/{project_id}/wiki-match", - dashboard_update_project_wiki_match_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/sync/projects", - dashboard_sync_projects_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/gigs/{engagement_id}/status", - dashboard_update_gig_status_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/gigs/{engagement_id}/applications", - dashboard_add_gig_application_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/gigs/{engagement_id}/applications/{application_id}/status", - dashboard_update_gig_application_status_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/onboarding", - dashboard_onboarding_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/onboarding/engineers", - dashboard_setup_engineer_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/onboarding/{contact_id}/onboarder", - dashboard_assign_onboarder_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/onboarding/{contact_id}/status", - dashboard_update_onboarding_status_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/onboarding/{contact_id}/email/draft", - dashboard_onboarding_email_draft_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/onboarding/{contact_id}/email/send", - dashboard_onboarding_email_send_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/audit-events", - dashboard_audit_events_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/agent", - dashboard_agent_report_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/configuration", - dashboard_configuration_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/configuration/{key}", - dashboard_update_configuration_handler, - methods=["PUT"], - ) - app.add_api_route( - "/dashboard/api/newsletter/suppressions", - dashboard_newsletter_suppressions_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/newsletter/status", - dashboard_newsletter_status_handler, - methods=["GET"], - ) - app.add_api_route( - "/dashboard/api/sync/people", - dashboard_sync_people_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/api/sync/newsletters", - dashboard_sync_newsletters_handler, - methods=["POST"], - ) - app.add_api_route( - "/dashboard/gigs/{item_id}", - dashboard_handler, - methods=["GET"], - response_model=None, - ) - app.add_api_route( - "/dashboard/projects/{item_id}", - dashboard_handler, - methods=["GET"], - response_model=None, - ) - - app.add_api_route("/jobs", jobs_handler, methods=["GET"]) - app.add_api_route("/jobs/{job_id}", job_status_handler, methods=["GET"]) - app.add_api_route("/jobs/{job_id}/rerun", rerun_job_handler, methods=["POST"]) - app.add_api_route("/jobs/resume-extract", resume_extract_handler, methods=["POST"]) - app.add_api_route("/jobs/resume-apply", resume_apply_handler, methods=["POST"]) - - app.add_api_route("/webhooks/espocrm", espocrm_webhook_handler, methods=["POST"]) - app.add_api_route( - "/webhooks/espocrm/people-sync", - espocrm_people_sync_webhook_handler, - methods=["POST"], - ) - app.add_api_route( - "/webhooks/docuseal", - docuseal_webhook_handler, - methods=["POST"], - ) - app.add_api_route( - "/webhooks/google-forms", - google_forms_intake_webhook_handler, - methods=["POST"], - ) - app.add_api_route( - "/webhooks/tally", - tally_intake_webhook_handler, - methods=["POST"], - ) - app.add_api_route( - "/webhooks/tally/onboarding", - tally_intake_webhook_handler, - methods=["POST"], - ) - app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"]) - - app.add_api_route( - "/process-contact/{contact_id}", - process_contact_handler, - methods=["POST"], - ) - app.add_api_route("/sync/people", sync_people_handler, methods=["POST"]) - app.add_api_route("/audit/events", audit_event_handler, methods=["POST"]) - app.add_api_route("/agent/requests", agent_request_handler, methods=["POST"]) - app.add_api_route( - "/agent/confirmations/{plan_id}", - agent_confirmation_handler, - methods=["POST"], - ) - - app.add_api_route( - "/auth/login", auth_login_handler, methods=["GET"], response_model=None - ) - app.add_api_route( - "/auth/callback", auth_callback_handler, methods=["GET"], response_model=None - ) - app.add_api_route("/auth/me", auth_me_handler, methods=["GET"]) - app.add_api_route("/auth/logout", auth_logout_handler, methods=["POST"]) - app.add_api_route( - "/auth/discord/links", - auth_discord_link_create_handler, - methods=["POST"], - ) - app.add_api_route( - "/auth/discord/link/{token}", - auth_discord_link_redirect_handler, - methods=["GET"], - response_model=None, - ) - app.add_api_route( - "/auth/discord/link/{token}/consume", - auth_discord_link_consume_handler, - methods=["POST"], - response_model=None, - ) + register_routes(app, cast(BackendRouteSurface, sys.modules[__name__])) return app diff --git a/apps/api/src/five08/backend/routes.py b/apps/api/src/five08/backend/routes.py new file mode 100644 index 00000000..c26bc3e4 --- /dev/null +++ b/apps/api/src/five08/backend/routes.py @@ -0,0 +1,485 @@ +"""Route registration for the backend FastAPI app.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any, Protocol + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + + +RouteHandler = Callable[..., Any] + + +class BackendRouteSurface(Protocol): + """Route-facing API module contract used by this registrar.""" + + _OptionalDirectoryStaticFiles: type[StaticFiles] + dashboard_assets_dir: Callable[[], Path] + agent_confirmation_handler: RouteHandler + agent_request_handler: RouteHandler + audit_event_handler: RouteHandler + auth_callback_handler: RouteHandler + auth_discord_link_consume_handler: RouteHandler + auth_discord_link_create_handler: RouteHandler + auth_discord_link_redirect_handler: RouteHandler + auth_login_handler: RouteHandler + auth_logout_handler: RouteHandler + auth_me_handler: RouteHandler + dashboard_add_gig_application_handler: RouteHandler + dashboard_add_project_historical_member_handler: RouteHandler + dashboard_add_project_user_handler: RouteHandler + dashboard_agent_report_handler: RouteHandler + dashboard_assign_onboarder_handler: RouteHandler + dashboard_audit_events_handler: RouteHandler + dashboard_bulk_update_projects_handler: RouteHandler + dashboard_configuration_handler: RouteHandler + dashboard_create_project_handler: RouteHandler + dashboard_erpnext_account_managers_handler: RouteHandler + dashboard_erpnext_contacts_handler: RouteHandler + dashboard_erpnext_cost_centers_handler: RouteHandler + dashboard_erpnext_customers_handler: RouteHandler + dashboard_gig_detail_handler: RouteHandler + dashboard_gigs_handler: RouteHandler + dashboard_handler: RouteHandler + dashboard_job_detail_handler: RouteHandler + dashboard_jobs_handler: RouteHandler + dashboard_me_handler: RouteHandler + dashboard_newsletter_status_handler: RouteHandler + dashboard_newsletter_suppressions_handler: RouteHandler + dashboard_notifications_handler: RouteHandler + dashboard_onboarding_email_draft_handler: RouteHandler + dashboard_onboarding_email_send_handler: RouteHandler + dashboard_onboarding_handler: RouteHandler + dashboard_people_handler: RouteHandler + dashboard_project_member_candidates_handler: RouteHandler + dashboard_project_wiki_matches_handler: RouteHandler + dashboard_projects_handler: RouteHandler + dashboard_remove_project_historical_member_handler: RouteHandler + dashboard_remove_project_user_handler: RouteHandler + dashboard_rerun_job_handler: RouteHandler + dashboard_setup_engineer_handler: RouteHandler + dashboard_sync_newsletters_handler: RouteHandler + dashboard_sync_people_handler: RouteHandler + dashboard_sync_projects_handler: RouteHandler + dashboard_update_configuration_handler: RouteHandler + dashboard_update_gig_application_status_handler: RouteHandler + dashboard_update_gig_status_handler: RouteHandler + dashboard_update_onboarding_status_handler: RouteHandler + dashboard_update_project_status_handler: RouteHandler + dashboard_update_project_wiki_match_handler: RouteHandler + docuseal_webhook_handler: RouteHandler + espocrm_people_sync_webhook_handler: RouteHandler + espocrm_webhook_handler: RouteHandler + google_forms_intake_webhook_handler: RouteHandler + health_handler: RouteHandler + ingest_handler: RouteHandler + job_status_handler: RouteHandler + jobs_handler: RouteHandler + process_contact_handler: RouteHandler + rerun_job_handler: RouteHandler + resume_apply_handler: RouteHandler + resume_extract_handler: RouteHandler + sync_people_handler: RouteHandler + tally_intake_webhook_handler: RouteHandler + + +def register_routes(app: FastAPI, api: BackendRouteSurface) -> None: + """Register backend API routes against handlers owned by the api module.""" + _OptionalDirectoryStaticFiles = api._OptionalDirectoryStaticFiles + agent_confirmation_handler = api.agent_confirmation_handler + agent_request_handler = api.agent_request_handler + audit_event_handler = api.audit_event_handler + auth_callback_handler = api.auth_callback_handler + auth_discord_link_consume_handler = api.auth_discord_link_consume_handler + auth_discord_link_create_handler = api.auth_discord_link_create_handler + auth_discord_link_redirect_handler = api.auth_discord_link_redirect_handler + auth_login_handler = api.auth_login_handler + auth_logout_handler = api.auth_logout_handler + auth_me_handler = api.auth_me_handler + dashboard_add_gig_application_handler = api.dashboard_add_gig_application_handler + dashboard_add_project_historical_member_handler = ( + api.dashboard_add_project_historical_member_handler + ) + dashboard_add_project_user_handler = api.dashboard_add_project_user_handler + dashboard_agent_report_handler = api.dashboard_agent_report_handler + dashboard_assets_dir = api.dashboard_assets_dir + dashboard_assign_onboarder_handler = api.dashboard_assign_onboarder_handler + dashboard_audit_events_handler = api.dashboard_audit_events_handler + dashboard_bulk_update_projects_handler = api.dashboard_bulk_update_projects_handler + dashboard_configuration_handler = api.dashboard_configuration_handler + dashboard_create_project_handler = api.dashboard_create_project_handler + dashboard_erpnext_account_managers_handler = ( + api.dashboard_erpnext_account_managers_handler + ) + dashboard_erpnext_contacts_handler = api.dashboard_erpnext_contacts_handler + dashboard_erpnext_cost_centers_handler = api.dashboard_erpnext_cost_centers_handler + dashboard_erpnext_customers_handler = api.dashboard_erpnext_customers_handler + dashboard_gig_detail_handler = api.dashboard_gig_detail_handler + dashboard_gigs_handler = api.dashboard_gigs_handler + dashboard_handler = api.dashboard_handler + dashboard_job_detail_handler = api.dashboard_job_detail_handler + dashboard_jobs_handler = api.dashboard_jobs_handler + dashboard_me_handler = api.dashboard_me_handler + dashboard_newsletter_status_handler = api.dashboard_newsletter_status_handler + dashboard_newsletter_suppressions_handler = ( + api.dashboard_newsletter_suppressions_handler + ) + dashboard_notifications_handler = api.dashboard_notifications_handler + dashboard_onboarding_email_draft_handler = ( + api.dashboard_onboarding_email_draft_handler + ) + dashboard_onboarding_email_send_handler = ( + api.dashboard_onboarding_email_send_handler + ) + dashboard_onboarding_handler = api.dashboard_onboarding_handler + dashboard_people_handler = api.dashboard_people_handler + dashboard_project_member_candidates_handler = ( + api.dashboard_project_member_candidates_handler + ) + dashboard_project_wiki_matches_handler = api.dashboard_project_wiki_matches_handler + dashboard_projects_handler = api.dashboard_projects_handler + dashboard_remove_project_historical_member_handler = ( + api.dashboard_remove_project_historical_member_handler + ) + dashboard_remove_project_user_handler = api.dashboard_remove_project_user_handler + dashboard_rerun_job_handler = api.dashboard_rerun_job_handler + dashboard_setup_engineer_handler = api.dashboard_setup_engineer_handler + dashboard_sync_newsletters_handler = api.dashboard_sync_newsletters_handler + dashboard_sync_people_handler = api.dashboard_sync_people_handler + dashboard_sync_projects_handler = api.dashboard_sync_projects_handler + dashboard_update_configuration_handler = api.dashboard_update_configuration_handler + dashboard_update_gig_application_status_handler = ( + api.dashboard_update_gig_application_status_handler + ) + dashboard_update_gig_status_handler = api.dashboard_update_gig_status_handler + dashboard_update_onboarding_status_handler = ( + api.dashboard_update_onboarding_status_handler + ) + dashboard_update_project_status_handler = ( + api.dashboard_update_project_status_handler + ) + dashboard_update_project_wiki_match_handler = ( + api.dashboard_update_project_wiki_match_handler + ) + docuseal_webhook_handler = api.docuseal_webhook_handler + espocrm_people_sync_webhook_handler = api.espocrm_people_sync_webhook_handler + espocrm_webhook_handler = api.espocrm_webhook_handler + google_forms_intake_webhook_handler = api.google_forms_intake_webhook_handler + health_handler = api.health_handler + ingest_handler = api.ingest_handler + job_status_handler = api.job_status_handler + jobs_handler = api.jobs_handler + process_contact_handler = api.process_contact_handler + rerun_job_handler = api.rerun_job_handler + resume_apply_handler = api.resume_apply_handler + resume_extract_handler = api.resume_extract_handler + sync_people_handler = api.sync_people_handler + tally_intake_webhook_handler = api.tally_intake_webhook_handler + + app.add_api_route("/", health_handler, methods=["GET"]) + app.add_api_route("/health", health_handler, methods=["GET"]) + + app.add_api_route( + "/dashboard", + dashboard_handler, + methods=["GET"], + response_model=None, + ) + assets_dir = dashboard_assets_dir() + app.mount( + "/dashboard/assets", + _OptionalDirectoryStaticFiles(directory=assets_dir, check_dir=False), + name="dashboard-assets", + ) + app.add_api_route( + "/dashboard/{view}", + dashboard_handler, + methods=["GET"], + response_model=None, + ) + app.add_api_route("/dashboard/api/me", dashboard_me_handler, methods=["GET"]) + app.add_api_route("/dashboard/api/jobs", dashboard_jobs_handler, methods=["GET"]) + app.add_api_route( + "/dashboard/api/jobs/{job_id}", + dashboard_job_detail_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/jobs/{job_id}/rerun", + dashboard_rerun_job_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/people", + dashboard_people_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/gigs", + dashboard_gigs_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/gigs/{engagement_id}", + dashboard_gig_detail_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/notifications", + dashboard_notifications_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/projects", + dashboard_projects_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/project-member-candidates", + dashboard_project_member_candidates_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/projects/wiki-matches", + dashboard_project_wiki_matches_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/erpnext/customers", + dashboard_erpnext_customers_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/erpnext/contacts", + dashboard_erpnext_contacts_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/erpnext/account-managers", + dashboard_erpnext_account_managers_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/erpnext/cost-centers", + dashboard_erpnext_cost_centers_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/projects/create", + dashboard_create_project_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/projects/bulk", + dashboard_bulk_update_projects_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/projects/{project_id}/status", + dashboard_update_project_status_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/projects/{project_id}/users", + dashboard_add_project_user_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/projects/{project_id}/users/remove", + dashboard_remove_project_user_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/projects/{project_id}/historical-members", + dashboard_add_project_historical_member_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/projects/{project_id}/historical-members/remove", + dashboard_remove_project_historical_member_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/projects/{project_id}/wiki-match", + dashboard_update_project_wiki_match_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/sync/projects", + dashboard_sync_projects_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/gigs/{engagement_id}/status", + dashboard_update_gig_status_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/gigs/{engagement_id}/applications", + dashboard_add_gig_application_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/gigs/{engagement_id}/applications/{application_id}/status", + dashboard_update_gig_application_status_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/onboarding", + dashboard_onboarding_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/onboarding/engineers", + dashboard_setup_engineer_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/onboarding/{contact_id}/onboarder", + dashboard_assign_onboarder_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/onboarding/{contact_id}/status", + dashboard_update_onboarding_status_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/onboarding/{contact_id}/email/draft", + dashboard_onboarding_email_draft_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/onboarding/{contact_id}/email/send", + dashboard_onboarding_email_send_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/audit-events", + dashboard_audit_events_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/agent", + dashboard_agent_report_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/configuration", + dashboard_configuration_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/configuration/{key}", + dashboard_update_configuration_handler, + methods=["PUT"], + ) + app.add_api_route( + "/dashboard/api/newsletter/suppressions", + dashboard_newsletter_suppressions_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/newsletter/status", + dashboard_newsletter_status_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/sync/people", + dashboard_sync_people_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/sync/newsletters", + dashboard_sync_newsletters_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/gigs/{item_id}", + dashboard_handler, + methods=["GET"], + response_model=None, + ) + app.add_api_route( + "/dashboard/projects/{item_id}", + dashboard_handler, + methods=["GET"], + response_model=None, + ) + + app.add_api_route("/jobs", jobs_handler, methods=["GET"]) + app.add_api_route("/jobs/{job_id}", job_status_handler, methods=["GET"]) + app.add_api_route("/jobs/{job_id}/rerun", rerun_job_handler, methods=["POST"]) + app.add_api_route("/jobs/resume-extract", resume_extract_handler, methods=["POST"]) + app.add_api_route("/jobs/resume-apply", resume_apply_handler, methods=["POST"]) + + app.add_api_route("/webhooks/espocrm", espocrm_webhook_handler, methods=["POST"]) + app.add_api_route( + "/webhooks/espocrm/people-sync", + espocrm_people_sync_webhook_handler, + methods=["POST"], + ) + app.add_api_route( + "/webhooks/docuseal", + docuseal_webhook_handler, + methods=["POST"], + ) + app.add_api_route( + "/webhooks/google-forms", + google_forms_intake_webhook_handler, + methods=["POST"], + ) + app.add_api_route( + "/webhooks/tally", + tally_intake_webhook_handler, + methods=["POST"], + ) + app.add_api_route( + "/webhooks/tally/onboarding", + tally_intake_webhook_handler, + methods=["POST"], + ) + app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"]) + + app.add_api_route( + "/process-contact/{contact_id}", + process_contact_handler, + methods=["POST"], + ) + app.add_api_route("/sync/people", sync_people_handler, methods=["POST"]) + app.add_api_route("/audit/events", audit_event_handler, methods=["POST"]) + app.add_api_route("/agent/requests", agent_request_handler, methods=["POST"]) + app.add_api_route( + "/agent/confirmations/{plan_id}", + agent_confirmation_handler, + methods=["POST"], + ) + + app.add_api_route( + "/auth/login", auth_login_handler, methods=["GET"], response_model=None + ) + app.add_api_route( + "/auth/callback", auth_callback_handler, methods=["GET"], response_model=None + ) + app.add_api_route("/auth/me", auth_me_handler, methods=["GET"]) + app.add_api_route("/auth/logout", auth_logout_handler, methods=["POST"]) + app.add_api_route( + "/auth/discord/links", + auth_discord_link_create_handler, + methods=["POST"], + ) + app.add_api_route( + "/auth/discord/link/{token}", + auth_discord_link_redirect_handler, + methods=["GET"], + response_model=None, + ) + app.add_api_route( + "/auth/discord/link/{token}/consume", + auth_discord_link_consume_handler, + methods=["POST"], + response_model=None, + ) diff --git a/apps/api/src/five08/backend/schemas.py b/apps/api/src/five08/backend/schemas.py new file mode 100644 index 00000000..35071f2e --- /dev/null +++ b/apps/api/src/five08/backend/schemas.py @@ -0,0 +1,188 @@ +"""Pydantic request schemas for the backend API.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from five08.agent import AgentIdentityContext + + +class ResumeExtractRequest(BaseModel): + """Request schema for queued resume extraction.""" + + contact_id: str + attachment_id: str + filename: str + refresh_token: str | None = None + + +class ResumeApplyRequest(BaseModel): + """Request schema for queued resume apply updates.""" + + contact_id: str + updates: dict[str, Any] + link_discord: dict[str, str] | None = None + + +class DiscordLinkCreateRequest(BaseModel): + """Payload for creating one-time admin deep links from Discord commands.""" + + discord_user_id: str + next_path: str | None = None + discord_display_name: str | None = None + discord_roles: list[str] = Field(default_factory=list) + + +class AgentConfirmationRequest(BaseModel): + """Payload for confirming or canceling a frozen agent plan.""" + + context: AgentIdentityContext + confirm: bool = True + + +class DashboardAssignOnboarderRequest(BaseModel): + """Payload for assigning an onboarder from the dashboard.""" + + onboarder: str + + +class DashboardOnboardingStatusRequest(BaseModel): + """Payload for updating one dashboard onboarding status.""" + + status: str + + +class DashboardOnboardingEmailDraftRequest(BaseModel): + """Payload for drafting one dashboard onboarding email.""" + + has_contributed: bool = False + discord_joined: Literal["yes", "no", "unknown"] = "unknown" + agreement_signed: Literal["yes", "no", "unknown"] = "unknown" + + +class DashboardOnboardingEmailSendRequest(BaseModel): + """Payload for sending one reviewed dashboard onboarding email.""" + + markdown_body: str + has_contributed: bool = False + discord_joined: Literal["yes", "no", "unknown"] = "unknown" + agreement_signed: Literal["yes", "no", "unknown"] = "unknown" + + +class DashboardGigStatusRequest(BaseModel): + """Payload for updating one dashboard gig status.""" + + status: str + + +class DashboardProjectStatusRequest(BaseModel): + """Payload for updating one ERPNext Project status.""" + + status: str + + +class DashboardBulkProjectUpdateRequest(BaseModel): + """Payload for bulk ERPNext Project field updates.""" + + project_ids: list[str] + status: str | None = None + project_type: str | None = None + + +class DashboardProjectUserRequest(BaseModel): + """Payload for adding one ERPNext User to a Project roster.""" + + user: str + candidate_id: str | None = None + activity_type: str | None = None + billing_rate: float | None = None + costing_rate: float | None = None + + +class DashboardEngineerSetupRequest(BaseModel): + """Payload for setting up one ERPNext engineer account.""" + + email: str + first_name: str + middle_name: str | None = None + last_name: str | None = None + country: str | None = None + gender: str | None = None + date_of_birth: str | None = None + date_of_joining: str | None = None + personal_email: str | None = None + prefered_email: str | None = None + + +class DashboardProjectUserRemoveRequest(BaseModel): + """Payload for removing one ERPNext User from a Project roster.""" + + user: str + + +class DashboardProjectHistoricalMemberRequest(BaseModel): + """Payload for adding one local historical Project roster member.""" + + person: str + candidate_id: str | None = None + + +class DashboardProjectHistoricalMemberRemoveRequest(BaseModel): + """Payload for removing one local historical Project roster member.""" + + source_user_id: str + + +class DashboardProjectWikiMatchRequest(BaseModel): + """Payload for saving a manual project-to-wiki match decision.""" + + status: str + row_key: str | None = None + + +class DashboardProjectCreateRequest(BaseModel): + """Payload for creating a Customer-backed ERPNext Project.""" + + project_name: str + customer_mode: Literal["new", "existing"] = "new" + customer_name: str | None = None + customer: str | None = None + account_manager: str | None = None + default_billing_currency: str | None = "USD" + default_cost_center: str | None = "Projects - 5" + activity_type: str | None = None + customer_details: str | None = None + customer_website: str | None = None + address_line1: str | None = None + address_line2: str | None = None + address_city: str | None = None + address_state: str | None = None + address_country: str | None = None + address_postal_code: str | None = None + contact: str | None = None + contact_first_name: str | None = None + contact_last_name: str | None = None + contact_email: str | None = None + contact_phone: str | None = None + contact_mobile: str | None = None + + +class DashboardGigApplicationStatusRequest(BaseModel): + """Payload for updating one dashboard gig candidate/application status.""" + + status: str + + +class DashboardGigApplicationCreateRequest(BaseModel): + """Payload for adding one CRM-verified gig candidate/application.""" + + crm_profile: str = Field(min_length=1, max_length=500) + + +class DashboardConfigurationUpdateRequest(BaseModel): + """Payload for updating one admin-managed configuration value.""" + + value: str | bool | int | float | None = None + clear: bool = False diff --git a/apps/discord_bot/src/five08/discord_bot/bot.py b/apps/discord_bot/src/five08/discord_bot/bot.py index 785f1ddc..0bf49b9f 100644 --- a/apps/discord_bot/src/five08/discord_bot/bot.py +++ b/apps/discord_bot/src/five08/discord_bot/bot.py @@ -81,14 +81,21 @@ async def setup_hook(self) -> None: async def load_extensions(self) -> None: """Load all cog files from the cogs directory.""" cogs_dir = Path(__file__).parent / "cogs" + cog_names: set[str] = set() for file in cogs_dir.glob("*.py"): if file.name != "__init__.py": - cog_name = f"five08.discord_bot.cogs.{file.stem}" - try: - await self.load_extension(cog_name) - logger.info(f"Loaded cog: {cog_name}") - except Exception as e: - logger.error(f"Failed to load cog {cog_name}: {e}") + cog_names.add(f"five08.discord_bot.cogs.{file.stem}") + + for package_init in cogs_dir.glob("*/__init__.py"): + if package_init.parent.name != "__pycache__": + cog_names.add(f"five08.discord_bot.cogs.{package_init.parent.name}") + + for cog_name in sorted(cog_names): + try: + await self.load_extension(cog_name) + logger.info(f"Loaded cog: {cog_name}") + except Exception as e: + logger.error(f"Failed to load cog {cog_name}: {e}") # Sync slash commands after loading all cogs try: diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm/__init__.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm/__init__.py new file mode 100644 index 00000000..b00fb723 --- /dev/null +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm/__init__.py @@ -0,0 +1,37 @@ +"""Compatibility package for the CRM cog implementation. + +The public extension/import path remains ``five08.discord_bot.cogs.crm`` while +the implementation lives in ``five08.discord_bot.cogs.crm.core``. +""" + +from __future__ import annotations + +import sys +from types import ModuleType + +from . import core as _core + +__all__ = [name for name in dir(_core) if not name.startswith("_")] + +for name in dir(_core): + if name.startswith("__") and name.endswith("__"): + continue + globals()[name] = getattr(_core, name) + + +class _CRMPackageModule(ModuleType): + def __getattr__(self, name: str) -> object: + return getattr(_core, name) + + def __setattr__(self, name: str, value: object) -> None: + if not (name.startswith("__") and name.endswith("__")) and hasattr(_core, name): + setattr(_core, name, value) + super().__setattr__(name, value) + + def __delattr__(self, name: str) -> None: + if not (name.startswith("__") and name.endswith("__")) and hasattr(_core, name): + delattr(_core, name) + super().__delattr__(name) + + +sys.modules[__name__].__class__ = _CRMPackageModule diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm/core.py similarity index 95% rename from apps/discord_bot/src/five08/discord_bot/cogs/crm.py rename to apps/discord_bot/src/five08/discord_bot/cogs/crm/core.py index db571713..4aeed54a 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm/core.py @@ -410,533 +410,438 @@ async def callback(self, interaction: discord.Interaction) -> None: ) -class CreateSSOUserSelectionButton(discord.ui.Button["CreateSSOUserSelectionView"]): - """Button for selecting a contact to continue SSO provisioning.""" - - def __init__(self, contact: dict[str, Any], requester_id: int) -> None: - contact_name = str(contact.get("name", "Unknown")) - label = contact_name[:80] if len(contact_name) > 80 else contact_name - super().__init__(style=discord.ButtonStyle.primary, label=label, emoji="🔐") - self.contact = contact - self.requester_id = requester_id - - async def callback(self, interaction: discord.Interaction) -> None: - """Handle contact selection and continue SSO provisioning.""" - try: - if not self.view: - await interaction.response.send_message( - "❌ View not found.", - ephemeral=True, - ) - return - if interaction.user.id != self.requester_id: - await interaction.response.send_message( - "❌ Only the command requester can confirm this action.", - ephemeral=True, - ) - return - - await interaction.response.defer(ephemeral=True) - await self.view.crm_cog._create_or_link_sso_user_for_contact( - interaction=interaction, - contact=self.contact, - search_term=self.view.search_term, - ) - - for item in self.view.children: - if isinstance(item, discord.ui.Button): - item.disabled = True - - if interaction.message: - try: - await interaction.message.edit(view=self.view) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning("Failed to update SSO user selection view: %s", exc) - except Exception as exc: - logger.error("Error in create_sso_user selection callback: %s", exc) - await interaction.followup.send( - "❌ An error occurred while handling the selection.", - ephemeral=True, - ) - +class RequesterContactSelectionView(discord.ui.View): + """Base view for requester-scoped CRM contact selections.""" -class CreateSSOUserSelectionView(discord.ui.View): - """View containing contact selection buttons for SSO provisioning.""" + button_cls: type["RequesterContactSelectionButton"] + timeout_log_message = "Failed to disable contact selection view: %s" def __init__( self, crm_cog: "CRMCog", requester_id: int, - search_term: str, - original_interaction: discord.Interaction, + search_term: str | None = None, ) -> None: super().__init__(timeout=300) self.crm_cog = crm_cog self.requester_id = requester_id - self.search_term = search_term - self.original_interaction = original_interaction + self.search_term = search_term or "" self._message: discord.Message | None = None + self._selection_started = False def add_contact_button(self, contact: dict[str, Any]) -> None: """Add a contact selection button.""" if len(self.children) >= 5: return - self.add_item( - CreateSSOUserSelectionButton( - contact=contact, - requester_id=self.requester_id, - ) - ) + self.add_item(self.create_contact_button(contact)) + + def create_contact_button( + self, contact: dict[str, Any] + ) -> "RequesterContactSelectionButton": + """Build the button for one contact.""" + return self.button_cls(contact=contact, requester_id=self.requester_id) def set_message(self, message: discord.Message | None) -> None: """Store the sent message so timeout can disable its controls.""" self._message = message - async def on_timeout(self) -> None: - """Disable controls when the selection times out and update the message.""" + def try_start_selection(self) -> bool: + """Mark this view as processing one selection if it has not started.""" + if self._selection_started: + return False + self._selection_started = True + return True + + def disable_controls(self) -> None: + """Disable all controls in the view.""" for item in self.children: if isinstance(item, discord.ui.Button): item.disabled = True + + def enable_controls(self) -> None: + """Re-enable all controls in the view.""" + for item in self.children: + if isinstance(item, discord.ui.Button): + item.disabled = False + + def reset_selection_state(self) -> None: + """Allow another selection attempt after a failed callback.""" + self._selection_started = False + self.enable_controls() + + async def edit_selection_message( + self, interaction: discord.Interaction, *, log_message: str + ) -> None: + """Update the original Discord message after controls change.""" + if interaction.message: + try: + await interaction.message.edit(view=self) + except discord.NotFound: + pass + except discord.HTTPException as exc: + logger.warning(log_message, exc) + + async def on_timeout(self) -> None: + """Disable controls when the selection times out and update the message.""" + self.disable_controls() if self._message: try: await self._message.edit(view=self) except discord.NotFound: pass except discord.HTTPException as exc: - logger.warning( - "Failed to disable create_sso_user selection view: %s", exc - ) + logger.warning(self.timeout_log_message, exc) -class CreateUserAccountsSelectionButton( - discord.ui.Button["CreateUserAccountsSelectionView"] -): - """Button for selecting a contact to continue all-in-one provisioning.""" +class RequesterContactSelectionButton(discord.ui.Button[RequesterContactSelectionView]): + """Base button for requester-scoped CRM contact selections.""" + + emoji_value = "✅" + style_value = discord.ButtonStyle.primary + callback_error_log = "Error in contact selection callback: %s" + callback_error_message = "❌ An error occurred while handling the selection." + edit_error_log = "Failed to update contact selection view: %s" + selection_already_processing_message = ( + "⚠️ This selection is already being processed." + ) + audit_action: str | None = None + use_selection_lock = False + disable_before_handle = False + reset_selection_on_error = True def __init__(self, contact: dict[str, Any], requester_id: int) -> None: - contact_name = str(contact.get("name", "Unknown")) - label = contact_name[:80] if len(contact_name) > 80 else contact_name - super().__init__(style=discord.ButtonStyle.primary, label=label, emoji="👤") + super().__init__( + style=self.button_style(contact), + label=self.button_label(contact), + emoji=self.button_emoji(contact), + ) self.contact = contact self.requester_id = requester_id + def button_label(self, contact: dict[str, Any]) -> str: + """Return the Discord button label for a contact.""" + contact_name = str(contact.get("name", "Unknown")) + return contact_name[:80] if len(contact_name) > 80 else contact_name + + def button_emoji(self, contact: dict[str, Any]) -> str: + """Return the Discord button emoji for a contact.""" + return self.emoji_value + + def button_style(self, contact: dict[str, Any]) -> discord.ButtonStyle: + """Return the Discord button style for a contact.""" + return self.style_value + async def callback(self, interaction: discord.Interaction) -> None: - """Handle contact selection and continue account provisioning.""" + """Validate a contact selection and run the concrete action.""" try: - if not self.view: + view = self.view + if not isinstance(view, RequesterContactSelectionView): await interaction.response.send_message( "❌ View not found.", ephemeral=True, ) return if interaction.user.id != self.requester_id: - await interaction.response.send_message( - "❌ Only the command requester can confirm this action.", - ephemeral=True, - ) + await self.handle_requester_mismatch(interaction, view) + return + if self.use_selection_lock and not view.try_start_selection(): + await self.handle_selection_already_processing(interaction, view) return await interaction.response.defer(ephemeral=True) - await self.view.crm_cog._create_user_accounts_for_contact( - interaction=interaction, - contact=self.contact, - search_term=self.view.search_term, - mailbox_username=self.view.mailbox_username, - ) + if self.disable_before_handle: + view.disable_controls() + await view.edit_selection_message( + interaction, + log_message=self.edit_error_log, + ) - for item in self.view.children: - if isinstance(item, discord.ui.Button): - item.disabled = True + await self.handle_selection(interaction, view) - if interaction.message: - try: - await interaction.message.edit(view=self.view) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - "Failed to update create_user_accounts selection view: %s", - exc, - ) + if not self.disable_before_handle: + view.disable_controls() + await view.edit_selection_message( + interaction, + log_message=self.edit_error_log, + ) except Exception as exc: - logger.error("Error in create_user_accounts selection callback: %s", exc) - await interaction.followup.send( - "❌ An error occurred while handling the selection.", - ephemeral=True, - ) - - -class CreateUserAccountsSelectionView(discord.ui.View): - """View containing contact selection buttons for combined account provisioning.""" + logger.error(self.callback_error_log, exc) + if self.reset_selection_on_error and isinstance( + self.view, RequesterContactSelectionView + ): + self.view.reset_selection_state() + await self.view.edit_selection_message( + interaction, + log_message=self.edit_error_log, + ) + await self.handle_callback_error(interaction, exc) - def __init__( + def _audit_selection_event( self, - crm_cog: "CRMCog", - requester_id: int, - search_term: str, - mailbox_username: str, + view: RequesterContactSelectionView, + interaction: discord.Interaction, + *, + result: str, + metadata: dict[str, Any], ) -> None: - super().__init__(timeout=300) - self.crm_cog = crm_cog - self.requester_id = requester_id - self.search_term = search_term - self.mailbox_username = mailbox_username - self._message: discord.Message | None = None - - def add_contact_button(self, contact: dict[str, Any]) -> None: - """Add a contact selection button.""" - if len(self.children) >= 5: + if not self.audit_action: return - self.add_item( - CreateUserAccountsSelectionButton( - contact=contact, - requester_id=self.requester_id, - ) + view.crm_cog._audit_command_safe( + interaction=interaction, + action=self.audit_action, + result=result, + metadata={ + **metadata, + "selected_contact_id": str(self.contact.get("id") or ""), + }, + resource_type="crm_contact", + resource_id=str(self.contact.get("id") or ""), ) - def set_message(self, message: discord.Message | None) -> None: - """Store the sent message so timeout can disable its controls.""" - self._message = message + async def handle_requester_mismatch( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + """Handle clicks by a user other than the original requester.""" + self._audit_selection_event( + interaction=interaction, + view=view, + result="denied", + metadata={"reason": "requester_mismatch"}, + ) + await interaction.response.send_message( + "❌ Only the command requester can confirm this action.", + ephemeral=True, + ) - async def on_timeout(self) -> None: - """Disable controls when the selection times out and update the message.""" - for item in self.children: - if isinstance(item, discord.ui.Button): - item.disabled = True - if self._message: - try: - await self._message.edit(view=self) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - "Failed to disable create_user_accounts selection view: %s", - exc, - ) + async def handle_selection_already_processing( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + """Handle duplicate clicks while a selection is already processing.""" + self._audit_selection_event( + interaction=interaction, + view=view, + result="denied", + metadata={"reason": "selection_already_processing"}, + ) + await interaction.response.send_message( + self.selection_already_processing_message, + ephemeral=True, + ) + async def handle_callback_error( + self, interaction: discord.Interaction, exc: Exception + ) -> None: + """Send a user-facing callback failure message.""" + if isinstance(self.view, RequesterContactSelectionView): + self._audit_selection_event( + interaction=interaction, + view=self.view, + result="error", + metadata={ + "stage": "selection_callback", + "error": str(exc), + }, + ) + if interaction.response.is_done(): + await interaction.followup.send( + self.callback_error_message, + ephemeral=True, + ) + else: + await interaction.response.send_message( + self.callback_error_message, + ephemeral=True, + ) -class OutlineInviteSelectionButton(discord.ui.Button["OutlineInviteSelectionView"]): - """Button for selecting a contact to invite to Outline.""" + async def handle_selection( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + """Run the concrete CRM action for the selected contact.""" + raise NotImplementedError - def __init__(self, contact: dict[str, Any], requester_id: int) -> None: - contact_name = str(contact.get("name", "Unknown")) - label = contact_name[:80] if len(contact_name) > 80 else contact_name - super().__init__(style=discord.ButtonStyle.primary, label=label, emoji="📨") - self.contact = contact - self.requester_id = requester_id - async def callback(self, interaction: discord.Interaction) -> None: - """Handle contact selection and send the Outline invite.""" - try: - if not self.view: - await interaction.response.send_message( - "❌ View not found.", - ephemeral=True, - ) - return - if interaction.user.id != self.requester_id: - await interaction.response.send_message( - "❌ Only the command requester can confirm this action.", - ephemeral=True, - ) - return +class CreateSSOUserSelectionButton(RequesterContactSelectionButton): + """Button for selecting a contact to continue SSO provisioning.""" - await interaction.response.defer(ephemeral=True) - await self.view.crm_cog._invite_outline_user_for_contact_flow( - interaction=interaction, - contact=self.contact, - search_term=self.view.search_term, - ) + emoji_value = "🔐" + callback_error_log = "Error in create_sso_user selection callback: %s" + edit_error_log = "Failed to update SSO user selection view: %s" + audit_action = "crm.create_sso_user" + use_selection_lock = True - for item in self.view.children: - if isinstance(item, discord.ui.Button): - item.disabled = True + async def handle_selection( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + await view.crm_cog._create_or_link_sso_user_for_contact( + interaction=interaction, + contact=self.contact, + search_term=view.search_term, + ) - if interaction.message: - try: - await interaction.message.edit(view=self.view) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - "Failed to update invite_outline_user selection view: %s", - exc, - ) - except Exception as exc: - logger.error("Error in invite_outline_user selection callback: %s", exc) - await interaction.followup.send( - "❌ An error occurred while handling the selection.", - ephemeral=True, - ) +class CreateSSOUserSelectionView(RequesterContactSelectionView): + """View containing contact selection buttons for SSO provisioning.""" -class OutlineInviteSelectionView(discord.ui.View): - """View containing contact selection buttons for Outline invitations.""" + button_cls = CreateSSOUserSelectionButton + timeout_log_message = "Failed to disable create_sso_user selection view: %s" def __init__( self, crm_cog: "CRMCog", requester_id: int, search_term: str, + original_interaction: discord.Interaction, ) -> None: - super().__init__(timeout=300) - self.crm_cog = crm_cog - self.requester_id = requester_id - self.search_term = search_term - self._message: discord.Message | None = None + super().__init__( + crm_cog=crm_cog, requester_id=requester_id, search_term=search_term + ) + self.original_interaction = original_interaction - def add_contact_button(self, contact: dict[str, Any]) -> None: - """Add a contact selection button.""" - if len(self.children) >= 5: - return - self.add_item( - OutlineInviteSelectionButton( - contact=contact, - requester_id=self.requester_id, - ) - ) - - def set_message(self, message: discord.Message | None) -> None: - """Store the sent message so timeout can disable its controls.""" - self._message = message - - async def on_timeout(self) -> None: - """Disable controls when the selection times out and update the message.""" - for item in self.children: - if isinstance(item, discord.ui.Button): - item.disabled = True - if self._message: - try: - await self._message.edit(view=self) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - "Failed to disable invite_outline_user selection view: %s", - exc, - ) +class CreateUserAccountsSelectionButton(RequesterContactSelectionButton): + """Button for selecting a contact to continue all-in-one provisioning.""" -class MemberAgreementSelectionButton(discord.ui.Button["MemberAgreementSelectionView"]): - """Button for selecting a contact to send the member agreement to.""" - - def __init__(self, contact: dict[str, Any], requester_id: int) -> None: - contact_name = str(contact.get("name", "Unknown")) - label = contact_name[:80] if len(contact_name) > 80 else contact_name - super().__init__(style=discord.ButtonStyle.primary, label=label, emoji="📄") - self.contact = contact - self.requester_id = requester_id - - async def callback(self, interaction: discord.Interaction) -> None: - """Handle contact selection and send the member agreement.""" - try: - if not self.view: - await interaction.response.send_message( - "❌ View not found.", - ephemeral=True, - ) - return - if interaction.user.id != self.requester_id: - self.view.crm_cog._audit_command_safe( - interaction=interaction, - action="crm.send_member_agreement", - result="denied", - metadata={ - "reason": "requester_mismatch", - "selected_contact_id": str(self.contact.get("id") or ""), - }, - resource_type="crm_contact", - resource_id=str(self.contact.get("id") or ""), - ) - await interaction.response.send_message( - "❌ Only the command requester can confirm this action.", - ephemeral=True, - ) - return - if not self.view.try_start_selection(): - self.view.crm_cog._audit_command_safe( - interaction=interaction, - action="crm.send_member_agreement", - result="denied", - metadata={ - "reason": "selection_already_processing", - "selected_contact_id": str(self.contact.get("id") or ""), - }, - resource_type="crm_contact", - resource_id=str(self.contact.get("id") or ""), - ) - await interaction.response.send_message( - "⚠️ This member agreement selection is already being processed.", - ephemeral=True, - ) - return + emoji_value = "👤" + callback_error_log = "Error in create_user_accounts selection callback: %s" + edit_error_log = "Failed to update create_user_accounts selection view: %s" + audit_action = "crm.create_user_accounts" + use_selection_lock = True - await interaction.response.defer(ephemeral=True) - self.view.disable_controls() - if interaction.message: - try: - await interaction.message.edit(view=self.view) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - "Failed to update send_member_agreement selection view: %s", - exc, - ) + async def handle_selection( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + assert isinstance(view, CreateUserAccountsSelectionView) + await view.crm_cog._create_user_accounts_for_contact( + interaction=interaction, + contact=self.contact, + search_term=view.search_term, + mailbox_username=view.mailbox_username, + ) - await self.view.crm_cog._send_member_agreement_for_contact_flow( - interaction=interaction, - contact=self.contact, - search_term=self.view.search_term, - ) - except Exception as exc: - logger.error("Error in send_member_agreement selection callback: %s", exc) - if self.view: - self.view.crm_cog._audit_command_safe( - interaction=interaction, - action="crm.send_member_agreement", - result="error", - metadata={ - "stage": "selection_callback", - "error": str(exc), - "selected_contact_id": str(self.contact.get("id") or ""), - }, - resource_type="crm_contact", - resource_id=str(self.contact.get("id") or ""), - ) - await interaction.followup.send( - "❌ An error occurred while handling the selection.", - ephemeral=True, - ) +class CreateUserAccountsSelectionView(RequesterContactSelectionView): + """View containing contact selection buttons for combined account provisioning.""" -class MemberAgreementSelectionView(discord.ui.View): - """View containing contact selection buttons for member agreements.""" + button_cls = CreateUserAccountsSelectionButton + timeout_log_message = "Failed to disable create_user_accounts selection view: %s" def __init__( self, crm_cog: "CRMCog", requester_id: int, search_term: str, + mailbox_username: str, ) -> None: - super().__init__(timeout=300) - self.crm_cog = crm_cog - self.requester_id = requester_id - self.search_term = search_term - self._message: discord.Message | None = None - self._selection_started = False + super().__init__( + crm_cog=crm_cog, requester_id=requester_id, search_term=search_term + ) + self.mailbox_username = mailbox_username - def add_contact_button(self, contact: dict[str, Any]) -> None: - """Add a contact selection button.""" - if len(self.children) >= 5: - return - self.add_item( - MemberAgreementSelectionButton( - contact=contact, - requester_id=self.requester_id, - ) + +class OutlineInviteSelectionButton(RequesterContactSelectionButton): + """Button for selecting a contact to invite to Outline.""" + + emoji_value = "📨" + callback_error_log = "Error in invite_outline_user selection callback: %s" + edit_error_log = "Failed to update invite_outline_user selection view: %s" + audit_action = "crm.invite_outline_user" + use_selection_lock = True + + async def handle_selection( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + await view.crm_cog._invite_outline_user_for_contact_flow( + interaction=interaction, + contact=self.contact, + search_term=view.search_term, ) - def set_message(self, message: discord.Message | None) -> None: - """Store the sent message so timeout can disable its controls.""" - self._message = message - def try_start_selection(self) -> bool: - """Mark this view as processing one selection if it has not started.""" - if self._selection_started: - return False - self._selection_started = True - return True +class OutlineInviteSelectionView(RequesterContactSelectionView): + """View containing contact selection buttons for Outline invitations.""" - def disable_controls(self) -> None: - """Disable all controls in the view.""" - for item in self.children: - if isinstance(item, discord.ui.Button): - item.disabled = True + button_cls = OutlineInviteSelectionButton + timeout_log_message = "Failed to disable invite_outline_user selection view: %s" - async def on_timeout(self) -> None: - """Disable controls when the selection times out and update the message.""" - self.disable_controls() - if self._message: - try: - await self._message.edit(view=self) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - "Failed to disable send_member_agreement selection view: %s", - exc, - ) +class MemberAgreementSelectionButton(RequesterContactSelectionButton): + """Button for selecting a contact to send the member agreement to.""" -class MarkIdVerifiedSelectionButton(discord.ui.Button["MarkIdVerifiedSelectionView"]): - """Button for selecting a contact to mark ID verification on.""" + emoji_value = "📄" + callback_error_log = "Error in send_member_agreement selection callback: %s" + callback_error_message = "❌ An error occurred while handling the selection." + edit_error_log = "Failed to update send_member_agreement selection view: %s" + selection_already_processing_message = ( + "⚠️ This member agreement selection is already being processed." + ) + audit_action = "crm.send_member_agreement" + use_selection_lock = True + disable_before_handle = True + reset_selection_on_error = False - def __init__( + async def handle_selection( self, - contact: dict[str, Any], - verified_by: str, - verified_at: str, - id_type: str | None, - requester_id: int, + interaction: discord.Interaction, + view: RequesterContactSelectionView, ) -> None: - contact_name = contact.get("name", "Unknown") - label = contact_name[:80] if len(contact_name) > 80 else contact_name - super().__init__(style=discord.ButtonStyle.success, label=label, emoji="✅") - self.contact = contact - self.verified_by = verified_by - self.verified_at = verified_at - self.id_type = id_type - self.requester_id = requester_id + await view.crm_cog._send_member_agreement_for_contact_flow( + interaction=interaction, + contact=self.contact, + search_term=view.search_term, + ) - async def callback(self, interaction: discord.Interaction) -> None: - """Handle contact selection and perform the ID verification.""" - try: - if not self.view: - await interaction.response.send_message("❌ View not found.") - return - if interaction.user.id != self.requester_id: - await interaction.response.send_message( - "❌ Only the command requester can confirm this action.", - ephemeral=True, - ) - return - await interaction.response.defer(ephemeral=True) - await self.view.crm_cog._mark_id_verified_for_contact( - interaction=interaction, - contact=self.contact, - verified_by=self.verified_by, - verified_at=self.verified_at, - id_type=self.id_type, - ) +class MemberAgreementSelectionView(RequesterContactSelectionView): + """View containing contact selection buttons for member agreements.""" - for item in self.view.children: - if isinstance(item, discord.ui.Button): - item.disabled = True + button_cls = MemberAgreementSelectionButton + timeout_log_message = "Failed to disable send_member_agreement selection view: %s" - if interaction.message: - try: - await interaction.message.edit(view=self.view) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - f"Failed to update ID verification selection view: {exc}" - ) - except Exception as exc: - logger.error(f"Error in ID verified selection callback: {exc}") - await interaction.followup.send( - "❌ An error occurred while marking ID verification.", - ephemeral=True, - ) +class MarkIdVerifiedSelectionButton(RequesterContactSelectionButton): + """Button for selecting a contact to mark ID verification on.""" -class MarkIdVerifiedSelectionView(discord.ui.View): + emoji_value = "✅" + style_value = discord.ButtonStyle.success + callback_error_log = "Error in ID verified selection callback: %s" + callback_error_message = "❌ An error occurred while marking ID verification." + edit_error_log = "Failed to update ID verification selection view: %s" + audit_action = "crm.mark_id_verified" + use_selection_lock = True + + async def handle_selection( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + assert isinstance(view, MarkIdVerifiedSelectionView) + await view.crm_cog._mark_id_verified_for_contact( + interaction=interaction, + contact=self.contact, + verified_by=view.verified_by, + verified_at=view.verified_at, + id_type=view.id_type, + ) + + +class MarkIdVerifiedSelectionView(RequesterContactSelectionView): """View containing contact selection buttons for ID verification.""" + button_cls = MarkIdVerifiedSelectionButton + timeout_log_message = "Failed to disable ID verification selection view: %s" + def __init__( self, crm_cog: "CRMCog", @@ -945,121 +850,70 @@ def __init__( verified_at: str, id_type: str | None, ) -> None: - super().__init__(timeout=300) # 5 minute timeout - self.crm_cog = crm_cog - self.requester_id = requester_id + super().__init__(crm_cog=crm_cog, requester_id=requester_id) self.verified_by = verified_by self.verified_at = verified_at self.id_type = id_type - def add_contact_button( - self, - contact: dict[str, Any], - ) -> None: - """Add a contact selection button.""" - if len(self.children) >= 5: - return - button = MarkIdVerifiedSelectionButton( - contact=contact, - verified_by=self.verified_by, - verified_at=self.verified_at, - id_type=self.id_type, - requester_id=self.requester_id, - ) - self.add_item(button) - -class ReprocessResumeSelectionButton(discord.ui.Button["ReprocessResumeSelectionView"]): +class ReprocessResumeSelectionButton(RequesterContactSelectionButton): """Button for selecting a contact to reprocess a profile.""" + callback_error_log = "Error in reprocess resume selection callback: %s" + edit_error_log = "Failed to update reprocess resume selection view: %s" + audit_action = "crm.reprocess_profile" + use_selection_lock = True + def __init__(self, contact: dict[str, Any], requester_id: int) -> None: - contact_name = str(contact.get("name", "Unknown")) resume_ids = contact.get("resumeIds") self.has_resume = isinstance(resume_ids, list) and any( str(item).strip() for item in resume_ids ) self.has_profile_sources = CRMCog._contact_has_external_profile_sources(contact) - can_reprocess_profile = self.has_resume or self.has_profile_sources - super().__init__( - style=( - discord.ButtonStyle.primary - if can_reprocess_profile - else discord.ButtonStyle.secondary - ), - label=_format_reprocess_profile_button_label( - contact_name, - has_resume=self.has_resume, - has_profile_sources=self.has_profile_sources, - ), - emoji="🔄" if can_reprocess_profile else "📤", - ) - self.contact = contact - self.requester_id = requester_id + super().__init__(contact=contact, requester_id=requester_id) - async def callback(self, interaction: discord.Interaction) -> None: - try: - if not self.view: - await interaction.response.send_message("❌ View not found.") - return - if interaction.user.id != self.requester_id: - await interaction.response.send_message( - "❌ Only the command requester can confirm this action.", - ephemeral=True, - ) - return + def button_label(self, contact: dict[str, Any]) -> str: + contact_name = str(contact.get("name", "Unknown")) + return _format_reprocess_profile_button_label( + contact_name, + has_resume=self.has_resume, + has_profile_sources=self.has_profile_sources, + ) - await interaction.response.defer(ephemeral=True) - if self.has_resume or self.has_profile_sources: - await self.view.crm_cog._start_resume_reprocess_from_contact( - interaction=interaction, - contact=self.contact, - search_term=self.view.search_term, - ) - else: - await self.view.crm_cog._prompt_upload_resume_for_contact( - interaction=interaction, - contact=self.contact, - search_term=self.view.search_term, - ) + def button_emoji(self, contact: dict[str, Any]) -> str: + if self.has_resume or self.has_profile_sources: + return "🔄" + return "📤" - for item in self.view.children: - if isinstance(item, discord.ui.Button): - item.disabled = True + def button_style(self, contact: dict[str, Any]) -> discord.ButtonStyle: + if self.has_resume or self.has_profile_sources: + return discord.ButtonStyle.primary + return discord.ButtonStyle.secondary - if interaction.message: - try: - await interaction.message.edit(view=self.view) - except discord.NotFound: - pass - except discord.HTTPException as exc: - logger.warning( - "Failed to update reprocess resume selection view: %s", exc - ) - except Exception as exc: - logger.error("Error in reprocess resume selection callback: %s", exc) - await interaction.followup.send( - "❌ An error occurred while handling the selection.", - ephemeral=True, + async def handle_selection( + self, + interaction: discord.Interaction, + view: RequesterContactSelectionView, + ) -> None: + if self.has_resume or self.has_profile_sources: + await view.crm_cog._start_resume_reprocess_from_contact( + interaction=interaction, + contact=self.contact, + search_term=view.search_term, + ) + else: + await view.crm_cog._prompt_upload_resume_for_contact( + interaction=interaction, + contact=self.contact, + search_term=view.search_term, ) -class ReprocessResumeSelectionView(discord.ui.View): +class ReprocessResumeSelectionView(RequesterContactSelectionView): """View containing contact selection buttons for resume reprocessing.""" - def __init__(self, crm_cog: "CRMCog", requester_id: int, search_term: str) -> None: - super().__init__(timeout=300) # 5 minute timeout - self.crm_cog = crm_cog - self.requester_id = requester_id - self.search_term = search_term - - def add_contact_button(self, contact: dict[str, Any]) -> None: - """Add a contact selection button.""" - if len(self.children) >= 5: - return - button = ReprocessResumeSelectionButton( - contact=contact, requester_id=self.requester_id - ) - self.add_item(button) + button_cls = ReprocessResumeSelectionButton + timeout_log_message = "Failed to disable reprocess resume selection view: %s" class MarkIdVerifiedOverwriteConfirmationView(discord.ui.View): diff --git a/apps/worker/src/five08/worker/crm/skills_extractor.py b/apps/worker/src/five08/worker/crm/skills_extractor.py index 6336dc5b..b68fddc5 100644 --- a/apps/worker/src/five08/worker/crm/skills_extractor.py +++ b/apps/worker/src/five08/worker/crm/skills_extractor.py @@ -1,256 +1,28 @@ -"""Skills extraction from resume text.""" +"""Worker compatibility shim for shared skills extraction.""" -import json -import logging -import re -from typing import Any - -from five08.llm import ProviderModel -from five08.openai_fallback import FallbackOpenAIClient -from five08.skills import ( - DISALLOWED_RESUME_SKILLS, - normalize_skill, - normalize_skill_payload, +from five08.resume_skills_extractor import COMMON_SKILLS as COMMON_SKILLS +from five08.resume_skills_extractor import ( + DEFAULT_SKILL_STRENGTH as DEFAULT_SKILL_STRENGTH, ) +from five08.resume_skills_extractor import DISALLOWED_SKILLS as DISALLOWED_SKILLS +from five08.resume_skills_extractor import SkillsExtractor as SharedSkillsExtractor from five08.worker.config import settings -from five08.worker.models import ExtractedSkills, SkillAttributes - -logger = logging.getLogger(__name__) - -try: # pragma: no cover - import success depends on installed dependencies - from openai import OpenAI as OpenAIClient -except Exception: # pragma: no cover - OpenAIClient = None # type: ignore[misc,assignment] - -COMMON_SKILLS = { - "python", - "javascript", - "typescript", - "java", - "go", - "rust", - "node", - "docker", - "kubernetes", - "amazon web services", - "google cloud", - "azure", - "postgresql", - "mysql", - "redis", - "react", - "django", - "flask", - "fastapi", - "git", - "linux", - "product management", - "go to market", - "ab testing", - "search engine optimization", - "search engine marketing", - "customer relationship management", - "google analytics", - "product marketing", - "content marketing", -} -DISALLOWED_SKILLS = DISALLOWED_RESUME_SKILLS +__all__ = [ + "COMMON_SKILLS", + "DEFAULT_SKILL_STRENGTH", + "DISALLOWED_SKILLS", + "SkillsExtractor", +] -DEFAULT_SKILL_STRENGTH = 3 - -class SkillsExtractor: - """Extract skills with LLM when configured, fallback heuristics otherwise.""" +class SkillsExtractor(SharedSkillsExtractor): + """Worker-specific wrapper bound to worker settings.""" def __init__(self) -> None: - self.provider_model = ProviderModel.openai_compatible( + super().__init__( model=settings.resolved_resume_ai_model, - api_key=settings.resolved_resume_ai_api_key, - base_url=settings.resolved_resume_ai_base_url, - ) - self.model = self.provider_model.model - self.client: Any = None - - if settings.resolved_resume_ai_provider_attempts and OpenAIClient is not None: - self.client = FallbackOpenAIClient( - providers=settings.resolved_resume_ai_provider_attempts, - client_factory=OpenAIClient, - ) - elif settings.resolved_resume_ai_api_key and OpenAIClient is not None: - self.client = OpenAIClient(**self.provider_model.client_kwargs()) - - def extract_skills(self, resume_text: str) -> ExtractedSkills: - """Extract skills from resume text.""" - if self.client is None: - return self._extract_skills_heuristic(resume_text) - - prompt = self._create_prompt(resume_text) - try: - response = self.client.chat.completions.create( - **self.provider_model.chat_completion_kwargs( - messages=[ - { - "role": "system", - "content": ( - "You extract professional skills from resumes for a CRM. " - "Focus on white-collar skills for product development orgs: " - "engineering, product, data, design, growth, and marketing. " - "Return JSON only, no prose. " - "Normalize skills to concise canonical names, lowercase. " - "Provide a strength from 1-5 when known, where 5 is strongest. " - "If uncertain, you may omit it or leave it blank. " - "Bias 3 for simple mentions, 4-5 for recent/current project usage, " - "and 1-2 for weak, outdated, or minimal exposure." - ), - }, - {"role": "user", "content": prompt}, - ], - temperature=0.1, - max_tokens=1200, - ) - ) - content = response.choices[0].message.content - if not content: - raise ValueError("LLM returned empty content") - - parsed = self._parse_llm_json(content) - confidence = float(parsed.get("confidence", 0.7)) - return self._normalize_extracted_payload( - skills_value=parsed.get("skills", []), - skill_attrs_value=parsed.get("skill_attrs", {}), - confidence=confidence, - source=self._last_llm_model(), - ) - except Exception as exc: - logger.warning("LLM skills extraction failed, using fallback: %s", exc) - return self._extract_skills_heuristic(resume_text) - - def _last_llm_model(self) -> str: - provider = getattr(self.client, "last_provider", None) - provider_model = getattr(provider, "model", None) - if isinstance(provider_model, str) and provider_model.strip(): - return provider_model.strip() - return self.model - - def _extract_skills_heuristic(self, resume_text: str) -> ExtractedSkills: - """Simple keyword and token-based extraction fallback.""" - lowered = resume_text.lower() - token_matches = re.findall(r"\b[a-z][a-z0-9+#\-.]{1,24}\b", lowered) - detected: set[str] = set() - for token in token_matches: - canonical = self._normalize_skill_name(token) - if canonical in COMMON_SKILLS and canonical not in DISALLOWED_SKILLS: - detected.add(canonical) - - sorted_skills = sorted(detected) - return ExtractedSkills( - skills=sorted_skills, - skill_attrs={ - skill: SkillAttributes(strength=DEFAULT_SKILL_STRENGTH) - for skill in sorted_skills - }, - confidence=0.45 if sorted_skills else 0.2, - source="heuristic", + openai_api_key=settings.resolved_resume_ai_api_key, + openai_base_url=settings.resolved_resume_ai_base_url, + provider_attempts=settings.resolved_resume_ai_provider_attempts, ) - - def _create_prompt(self, resume_text: str) -> str: - """Prompt template for LLM extraction.""" - snippet = resume_text[:8000] - return ( - "Analyze the resume and extract a concise skill list.\n" - "Use white-collar/product-development relevance only: engineering, product, " - "data, design, growth, and marketing.\n" - "Exclude personal traits and vague soft skills unless role-critical.\n" - "Return JSON with this exact schema:\n" - '{"skills": ["skill1", "skill2", "skill3 (4)"], ' - '"confidence": 0.8}\n' - "Rules:\n" - "- skills must be lowercase canonical names with minimal punctuation\n" - '- prefer forms like "nodejs", "ab testing", "go to market"\n' - "- optional strength may be included inline for a skill in parentheses, e.g. skill (4)\n" - "- if strength is uncertain, omit the suffix or use an empty suffix, e.g. skill ()\n" - "- strength is integer 1-5 (5 strongest), and should be assigned per above.\n" - "- use 3 when a skill is simply mentioned without strong context\n" - "- use 4 or 5 when usage is clearly current or recent in project work\n" - "- use 2 for older, side, or weak mentions and 1 for very weak/outdated evidence\n" - "- no extra keys\n\n" - f"Resume:\n{snippet}" - ) - - def _parse_llm_json(self, content: str) -> dict[str, Any]: - raw = content.strip() - if raw.startswith("```"): - lines = [line for line in raw.splitlines() if not line.startswith("```")] - raw = "\n".join(lines).strip() - - parsed = json.loads(raw) - if not isinstance(parsed, dict): - raise ValueError("skills extraction output was not a JSON object") - return parsed - - def _normalize_extracted_payload( - self, - *, - skills_value: Any, - skill_attrs_value: Any, - confidence: float, - source: str, - ) -> ExtractedSkills: - deduped_skills, normalized_attrs = normalize_skill_payload( - skills_value=skills_value, - skill_attrs_value=skill_attrs_value, - disallowed=DISALLOWED_SKILLS, - ) - ordered_skills = sorted(deduped_skills) - attrs_map = { - skill: SkillAttributes(strength=strength) - for skill, strength in normalized_attrs.items() - } - - return ExtractedSkills( - skills=ordered_skills, - skill_attrs=attrs_map, - confidence=max(0.0, min(1.0, confidence)), - source=source, - ) - - def _parse_strength(self, value: Any) -> int | None: - raw: Any = value - if isinstance(value, dict): - raw = value.get("strength") - if raw is None: - return None - if isinstance(raw, str) and not raw.strip(): - return None - try: - numeric = int(float(raw)) - except Exception: - return None - if numeric < 1 or numeric > 5: - return None - return numeric - - def _parse_skill_with_strength(self, value: str) -> tuple[str, int | None]: - raw = value.strip() - match = re.match(r"^(.*)\(\s*(\d*)\s*\)\s*$", raw) - if match is None: - return self._normalize_skill_name(raw), None - - base = match.group(1).strip() - parsed_strength = self._parse_strength(match.group(2)) - if not base: - return "", None - normalized_base = self._normalize_skill_name(base) - if not normalized_base: - return "", None - if parsed_strength is None: - return normalized_base, None - return normalized_base, parsed_strength - - def _normalize_skill_name(self, value: str) -> str: - return normalize_skill(value) - - def canonicalize_skill(self, value: str) -> str: - """Public helper for consistent skill normalization across processors.""" - return self._normalize_skill_name(value) diff --git a/packages/shared/src/five08/model_catalog.py b/packages/shared/src/five08/model_catalog.py index ff1f0df6..8765fddc 100644 --- a/packages/shared/src/five08/model_catalog.py +++ b/packages/shared/src/five08/model_catalog.py @@ -3,11 +3,14 @@ from __future__ import annotations import json +import logging from functools import lru_cache from importlib.resources import files from pathlib import Path from typing import Any +logger = logging.getLogger(__name__) + _EMPTY_MODEL_PROFILES: dict[str, Any] = { "version": "model-profiles.v1", "models": {}, @@ -29,24 +32,19 @@ def default_model_profiles_path() -> Path: - """Return the development/eval model profile catalog path.""" - return ( - Path(__file__).resolve().parents[4] / "tests" / "evals" / "model-profiles.json" - ) + """Return the packaged model profile catalog path in a source checkout.""" + return Path(__file__).resolve().parent / _PACKAGED_MODEL_PROFILES @lru_cache(maxsize=1) def load_model_profiles() -> dict[str, Any]: - """Load model profile metadata from packaged data with a dev fallback.""" + """Load model profile metadata from packaged data.""" packaged_data = _load_packaged_model_profiles() if packaged_data is not None: return _normalize_model_profiles(packaged_data) - path = default_model_profiles_path() - if not path.exists(): - return dict(_EMPTY_MODEL_PROFILES) - data = json.loads(path.read_text()) - return _normalize_model_profiles(data) + logger.warning("Packaged model profile catalog unavailable; using empty catalog") + return dict(_EMPTY_MODEL_PROFILES) def _load_packaged_model_profiles() -> dict[str, Any] | None: diff --git a/tests/evals/model-profiles.json b/tests/evals/model-profiles.json deleted file mode 100644 index bff9d4d1..00000000 --- a/tests/evals/model-profiles.json +++ /dev/null @@ -1,609 +0,0 @@ -{ - "version": "model-profiles.v1", - "pricing_source": "official provider pricing docs; OpenAI org usage/cost API requires api.usage.read", - "models": { - "gpt-5.5": { - "name": "gpt-5.5", - "provider": "openai-compatible", - "model": "gpt-5.5", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "low", - "verbosity": "low", - "supports_temperature": false - }, - "cost_per_1m": { - "input": 5.0, - "cached": 0.5, - "output": 30.0 - }, - "observed_behavior": { - "resume_baseline": { - "date": "2026-05-12", - "corpus": "local-resumes", - "succeeded": 11, - "total": 11, - "estimated_cost_usd": 0.386096, - "total_elapsed_ms": 203051, - "p50_latency_ms": 17570, - "p95_latency_ms": 32016, - "suggested_use": "quality ceiling / review baseline" - }, - "live_planner_weekly": { - "date": "2026-05-13", - "suite": "discord-agent-weekly", - "eval_profile": "openai-direct", - "model": "gpt-5.5", - "passed": 13, - "total": 13, - "parse_success_rate": 1.0, - "bad_plan_rate": 0.0, - "avg_latency_ms": 2653.5, - "max_latency_ms": 3982, - "suggested_use": "planner quality ceiling; too expensive for default bulk planning" - } - }, - "baseline_chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "medium", - "verbosity": "low", - "supports_temperature": false - } - }, - "gpt-5.4": { - "name": "gpt-5.4", - "provider": "openai-compatible", - "model": "gpt-5.4", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "low", - "verbosity": "low", - "supports_temperature": false - }, - "cost_per_1m": { - "input": 2.5, - "cached": 0.25, - "output": 15.0 - }, - "observed_behavior": { - "resume_extraction": { - "date": "2026-05-12", - "corpus": "local-resumes", - "llm_successes": 11, - "total": 11, - "avg_score": 0.9422, - "estimated_cost_usd": 0.191121, - "total_elapsed_ms": 158040, - "p50_latency_ms": 13077, - "p95_latency_ms": 21933, - "suggested_use": "high-cost quality fallback" - } - } - }, - "gpt-5.4-mini": { - "name": "gpt-5.4-mini", - "provider": "openai-compatible", - "model": "gpt-5.4-mini", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "low", - "verbosity": "low", - "supports_temperature": false - }, - "cost_per_1m": { - "input": 0.75, - "cached": 0.075, - "output": 4.5 - } - }, - "gpt-5.4-nano": { - "name": "gpt-5.4-nano", - "provider": "openai-compatible", - "model": "gpt-5.4-nano", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "low", - "verbosity": "low", - "supports_temperature": false - }, - "cost_per_1m": { - "input": 0.2, - "cached": 0.02, - "output": 1.25 - }, - "observed_behavior": { - "resume_extraction": { - "date": "2026-05-12", - "corpus": "local-resumes", - "llm_successes": 10, - "total": 11, - "avg_score": 0.8998, - "estimated_cost_usd": 0.013254, - "total_elapsed_ms": 178075, - "p50_latency_ms": 7560, - "p95_latency_ms": 73269, - "suggested_use": "not default; one fallback in latest run" - } - } - }, - "gpt-5.1": { - "name": "gpt-5.1", - "provider": "openai-compatible", - "model": "gpt-5.1", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "low", - "verbosity": "low", - "supports_temperature": false - } - }, - "gpt-5": { - "name": "gpt-5", - "provider": "openai-compatible", - "model": "gpt-5", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "minimal", - "verbosity": "low", - "supports_temperature": false - }, - "cost_per_1m": { - "input": 1.25, - "cached": 0.125, - "output": 10.0 - }, - "observed_behavior": { - "resume_extraction": { - "date": "2026-05-12", - "corpus": "local-resumes", - "llm_successes": 11, - "total": 11, - "avg_score": 0.9422, - "estimated_cost_usd": 0.087674, - "total_elapsed_ms": 282013, - "p50_latency_ms": 25908, - "p95_latency_ms": 65789, - "suggested_use": "quality fallback" - } - } - }, - "gpt-5-mini": { - "name": "gpt-5-mini", - "provider": "openai-compatible", - "model": "gpt-5-mini", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "minimal", - "verbosity": "low", - "supports_temperature": false - }, - "cost_per_1m": { - "input": 0.25, - "cached": 0.025, - "output": 2.0 - }, - "observed_behavior": { - "resume_extraction": { - "date": "2026-05-12", - "corpus": "local-resumes", - "llm_successes": 11, - "total": 11, - "avg_score": 0.9422, - "estimated_cost_usd": 0.020147, - "total_elapsed_ms": 130045, - "p50_latency_ms": 8416, - "p95_latency_ms": 47531, - "suggested_use": "cheap fallback candidate" - }, - "live_planner_weekly_openai_direct": { - "date": "2026-05-13", - "suite": "discord-agent-weekly", - "eval_profile": "openai-direct", - "model": "gpt-5-mini", - "passed": 11, - "total": 13, - "parse_success_rate": 1.0, - "bad_plan_rate": 0.1538, - "avg_latency_ms": 2316.9, - "max_latency_ms": 3421, - "suggested_use": "not the current live planner default; failed GitHub issue scenarios in rerun" - }, - "live_planner_weekly_openrouter": { - "date": "2026-05-12", - "suite": "discord-agent-weekly", - "eval_profile": "openrouter", - "model": "openai/gpt-5-mini", - "passed": 4, - "total": 13, - "parse_success_rate": 1.0, - "bad_plan_rate": 0.6923, - "avg_latency_ms": 5083.6, - "max_latency_ms": 8309, - "suggested_use": "not the current live planner default; weaker than Kimi K2.6 in retained weekly reports" - } - } - }, - "gpt-5-nano": { - "name": "gpt-5-nano", - "provider": "openai-compatible", - "model": "gpt-5-nano", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "chat_completion_options": { - "max_tokens_parameter": "max_completion_tokens", - "reasoning_effort": "minimal", - "verbosity": "low", - "supports_temperature": false - }, - "cost_per_1m": { - "input": 0.05, - "cached": 0.005, - "output": 0.4 - }, - "observed_behavior": { - "live_planner_weekly": { - "date": "2026-05-13", - "suite": "discord-agent-weekly", - "eval_profile": "openai-direct", - "model": "gpt-5-nano", - "passed": 9, - "total": 13, - "parse_success_rate": 1.0, - "bad_plan_rate": 0.3077, - "avg_latency_ms": 2168.8, - "max_latency_ms": 3444, - "suggested_use": "too weak for live planning default" - } - } - }, - "gpt-4.1": { - "name": "gpt-4.1", - "provider": "openai-compatible", - "model": "gpt-4.1", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 2.0, - "cached": 0.5, - "output": 8.0 - }, - "observed_behavior": { - "resume_extraction": { - "date": "2026-05-12", - "corpus": "local-resumes", - "llm_successes": 11, - "total": 11, - "avg_score": 0.9422, - "estimated_cost_usd": 0.114688, - "total_elapsed_ms": 151975, - "p50_latency_ms": 13723, - "p95_latency_ms": 22797, - "suggested_use": "high-quality fallback" - } - } - }, - "gpt-4.1-mini": { - "name": "gpt-4.1-mini", - "provider": "openai-compatible", - "model": "gpt-4.1-mini", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 0.4, - "cached": 0.1, - "output": 1.6 - }, - "observed_behavior": { - "resume_extraction": { - "date": "2026-05-12", - "corpus": "local-resumes", - "llm_successes": 11, - "total": 11, - "avg_score": 0.9504, - "estimated_cost_usd": 0.023059, - "total_elapsed_ms": 345350, - "p50_latency_ms": 11636, - "p95_latency_ms": 230770, - "suggested_use": "default candidate; ignore observed long-tail latency until rerun on stable network" - }, - "live_planner_weekly": { - "date": "2026-05-13", - "suite": "discord-agent-weekly", - "eval_profile": "openai-direct", - "model": "gpt-4.1-mini", - "passed": 13, - "total": 13, - "parse_success_rate": 1.0, - "bad_plan_rate": 0.0, - "avg_latency_ms": 2634.5, - "max_latency_ms": 3189, - "suggested_use": "best OpenAI default candidate for live planning; 13/13 pass at much lower cost than gpt-5.5" - } - } - }, - "gpt-4.1-nano": { - "name": "gpt-4.1-nano", - "provider": "openai-compatible", - "model": "gpt-4.1-nano", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 0.1, - "cached": 0.03, - "output": 0.4 - } - }, - "gpt-4o-mini": { - "name": "gpt-4o-mini", - "provider": "openai-compatible", - "model": "gpt-4o-mini", - "api_key_env": "OPENAI_API_KEY", - "base_url": "https://api.openai.com/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - } - }, - "kimi-k2p6-turbo-fireworks": { - "name": "kimi-k2p6-turbo-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/routers/kimi-k2p6-turbo", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 1.5, - "cached": 0.22, - "output": 6.0 - } - }, - "kimi-k2p6-fireworks": { - "name": "kimi-k2p6-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/models/kimi-k2p6", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 0.95, - "cached": 0.16, - "output": 4.0 - }, - "observed_behavior": { - "live_planner_weekly": { - "date": "2026-05-12", - "suite": "discord-agent-weekly", - "eval_profile": "fireworks-kimi", - "model": "accounts/fireworks/models/kimi-k2p6", - "passed": 13, - "total": 13, - "parse_success_rate": 1.0, - "bad_plan_rate": 0.0, - "avg_latency_ms": 1942.6, - "max_latency_ms": 3939, - "suggested_use": "best low-latency/cost live planner candidate in rerun; keep deterministic policy gates and confirmations before write actions" - } - } - }, - "kimi-k2p5-fireworks": { - "name": "kimi-k2p5-fireworks", - "provider": "openai-compatible", - "model": "fireworks/kimi-k2p5", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 0.6, - "cached": 0.1, - "output": 3.0 - } - }, - "kimi-k2p5-turbo-fireworks": { - "name": "kimi-k2p5-turbo-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/routers/kimi-k2p5-turbo", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 0.6, - "cached": 0.1, - "output": 3.0 - } - }, - "deepseek-v4-pro-fireworks": { - "name": "deepseek-v4-pro-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/models/deepseek-v4-pro", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 1.74, - "cached": 0.145, - "output": 3.48 - } - }, - "glm-5p1-fireworks": { - "name": "glm-5p1-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/models/glm-5p1", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 1.4, - "cached": 0.26, - "output": 4.4 - } - }, - "glm-5p1-fast-fireworks": { - "name": "glm-5p1-fast-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/routers/glm-5p1-fast", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 2.8, - "cached": 0.52, - "output": 8.8 - } - }, - "minimax-m2p7-fireworks": { - "name": "minimax-m2p7-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/models/minimax-m2p7", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 0.3, - "cached": 0.06, - "output": 1.2 - } - }, - "qwen3p6-plus-fireworks": { - "name": "qwen3p6-plus-fireworks", - "provider": "openai-compatible", - "model": "accounts/fireworks/models/qwen3p6-plus", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - }, - "cost_per_1m": { - "input": 0.15, - "cached": 0.075, - "output": 0.6 - } - }, - "openrouter-auto": { - "name": "openrouter-auto", - "provider": "openai-compatible", - "model": "openrouter/auto", - "api_key_env": "OPENROUTER_API_KEY", - "base_url": "https://openrouter.ai/api/v1", - "request_options": { - "response_format": { - "type": "json_object" - } - } - } - }, - "recommendations": { - "resume_extraction_default": { - "model": "gpt-4.1-mini", - "reason": "11/11 LLM success, highest observed completeness score, and low estimated cost in the 2026-05-12 local-resumes run; rerun latency on stable network before using latency as a deciding factor." - }, - "planner_live_default": { - "model": "kimi-k2p6-fireworks", - "eval_profile": "fireworks-kimi", - "reason": "Best default tradeoff in the fixed 2026-05-13 Discord live-planner weekly rerun: 13/13 passed with 13/13 parsed, 0% bad-plan rate, and 1942.6ms average latency. gpt-4.1-mini and gpt-5.5 also passed 13/13; gpt-4.1-mini is the best OpenAI fallback, while gpt-5.5 is a quality ceiling. Keep deterministic policy gates and human confirmations for write actions." - } - } -} diff --git a/tests/evals/resume-extraction/README.md b/tests/evals/resume-extraction/README.md index 08607771..4b6b73bf 100644 --- a/tests/evals/resume-extraction/README.md +++ b/tests/evals/resume-extraction/README.md @@ -21,7 +21,7 @@ Reports are written to `tests/evals/resume-extraction/reports/`, which is gitignored because observations can contain candidate PII. Model profile metadata, pricing, request behavior, and observed recommendations -live in `tests/evals/model-profiles.json`. Current local results mark +live in `packages/shared/src/five08/data/model-profiles.json`. Current local results mark `gpt-4.1-mini` as the resume extraction default candidate, with `gpt-5.5` kept as the quality-ceiling baseline. diff --git a/tests/unit/test_bot.py b/tests/unit/test_bot.py index 2faf1918..d5296006 100644 --- a/tests/unit/test_bot.py +++ b/tests/unit/test_bot.py @@ -59,7 +59,12 @@ async def test_load_extensions_loads_py_files(self): mock_features_dir.glob.return_value = [mock_file1, mock_file2] - with patch.object(Path, "glob", return_value=[mock_file1, mock_file2]): + def glob_side_effect(pattern: str): + if pattern == "*.py": + return [mock_file1, mock_file2] + return [] + + with patch.object(Path, "glob", side_effect=glob_side_effect): with patch.object( bot, "load_extension", new_callable=AsyncMock ) as mock_load_ext: @@ -79,7 +84,12 @@ async def test_load_extensions_handles_errors(self, caplog): mock_file.name = "broken_feature.py" mock_file.stem = "broken_feature" - with patch.object(Path, "glob", return_value=[mock_file]): + def glob_side_effect(pattern: str): + if pattern == "*.py": + return [mock_file] + return [] + + with patch.object(Path, "glob", side_effect=glob_side_effect): with patch.object( bot, "load_extension", side_effect=Exception("Load error") ): diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index ac6c9cb4..4673662b 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -7801,7 +7801,17 @@ async def test_member_agreement_selection_button_sends_selected_contact( ) view.add_contact_button(contact) button = view.children[0] - crm_cog._send_member_agreement_for_contact_flow = AsyncMock() + + async def assert_controls_disabled_during_send(**_: object) -> None: + assert all( + item.disabled + for item in view.children + if isinstance(item, discord.ui.Button) + ) + + crm_cog._send_member_agreement_for_contact_flow = AsyncMock( + side_effect=assert_controls_disabled_during_send + ) interaction = AsyncMock() interaction.response = AsyncMock() @@ -7891,6 +7901,7 @@ async def test_member_agreement_selection_button_audits_callback_error( interaction = AsyncMock() interaction.response = AsyncMock() interaction.response.defer = AsyncMock() + interaction.response.is_done = Mock(return_value=True) interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() @@ -7904,6 +7915,12 @@ async def test_member_agreement_selection_button_audits_callback_error( assert audit_kwargs["result"] == "error" assert audit_kwargs["metadata"]["stage"] == "selection_callback" assert audit_kwargs["metadata"]["selected_contact_id"] == "crm-123" + assert view._selection_started is True + assert all( + item.disabled + for item in view.children + if isinstance(item, discord.ui.Button) + ) interaction.followup.send.assert_awaited_once_with( "❌ An error occurred while handling the selection.", ephemeral=True, diff --git a/tests/unit/test_model_catalog.py b/tests/unit/test_model_catalog.py index 424d591b..a44f785a 100644 --- a/tests/unit/test_model_catalog.py +++ b/tests/unit/test_model_catalog.py @@ -18,9 +18,10 @@ def test_model_catalog_loads_packaged_json() -> None: assert "gpt-4.1-mini" in profiles["models"] -def test_model_catalog_keeps_repo_eval_json_available() -> None: +def test_model_catalog_uses_packaged_json_as_default_path() -> None: assert default_model_profiles_path().name == "model-profiles.json" assert default_model_profiles_path().exists() + assert "packages/shared/src/five08/data" in str(default_model_profiles_path()) profile = model_profile_for("gpt-4.1-mini")