diff --git a/.changeset/addie-thread-sharing.md b/.changeset/addie-thread-sharing.md new file mode 100644 index 0000000000..ec380ec43f --- /dev/null +++ b/.changeset/addie-thread-sharing.md @@ -0,0 +1,3 @@ +--- +--- + diff --git a/.changeset/afraid-hairs-add.md b/.changeset/afraid-hairs-add.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/afraid-hairs-add.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/block-gmail-corp-domain.md b/.changeset/block-gmail-corp-domain.md new file mode 100644 index 0000000000..aefb92e1e1 --- /dev/null +++ b/.changeset/block-gmail-corp-domain.md @@ -0,0 +1,4 @@ +--- +--- + +Fix domain health page to exclude free email providers (gmail.com, etc.) from "Related Organizations" section diff --git a/.changeset/block-personal-workspace-merge.md b/.changeset/block-personal-workspace-merge.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/block-personal-workspace-merge.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/bright-kids-say.md b/.changeset/bright-kids-say.md new file mode 100644 index 0000000000..d8ebeebfc6 --- /dev/null +++ b/.changeset/bright-kids-say.md @@ -0,0 +1,4 @@ +--- +--- + +fix: correct GitHub issue drafting repo and add confidentiality guidelines for Addie diff --git a/.changeset/bumpy-cycles-hear.md b/.changeset/bumpy-cycles-hear.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/bumpy-cycles-hear.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/busy-swans-judge.md b/.changeset/busy-swans-judge.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/busy-swans-judge.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/clear-colts-wink.md b/.changeset/clear-colts-wink.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/clear-colts-wink.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/cuddly-windows-accept.md b/.changeset/cuddly-windows-accept.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/cuddly-windows-accept.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/david-event-mgmt-fix.md b/.changeset/david-event-mgmt-fix.md new file mode 100644 index 0000000000..b0344a2e57 --- /dev/null +++ b/.changeset/david-event-mgmt-fix.md @@ -0,0 +1,4 @@ +--- +--- + +Fix chapter leader event management permission checks for users added via Slack diff --git a/.changeset/dirty-meteors-sell.md b/.changeset/dirty-meteors-sell.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/dirty-meteors-sell.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/eighty-onions-leave.md b/.changeset/eighty-onions-leave.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/eighty-onions-leave.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/eighty-sloths-feel.md b/.changeset/eighty-sloths-feel.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/eighty-sloths-feel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/eleven-wolves-return.md b/.changeset/eleven-wolves-return.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/eleven-wolves-return.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/every-falcons-peel.md b/.changeset/every-falcons-peel.md new file mode 100644 index 0000000000..a0791a3b6b --- /dev/null +++ b/.changeset/every-falcons-peel.md @@ -0,0 +1,4 @@ +--- +--- + +Update Addie's core thesis to incorporate the allocation vs efficiency framing. Advertisers are stuck at 3-5 platforms due to execution costs - the opportunity is scaling to 20+ partners, not optimizing the existing 3-5. Walled gardens benefit from AdCP because it lets them capture new allocation budgets without commoditizing their differentiation. diff --git a/.changeset/every-moose-kick.md b/.changeset/every-moose-kick.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/every-moose-kick.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/famous-sides-hide.md b/.changeset/famous-sides-hide.md new file mode 100644 index 0000000000..25bd707397 --- /dev/null +++ b/.changeset/famous-sides-hide.md @@ -0,0 +1,7 @@ +--- +--- + +feat: Add membership metrics to admin analytics dashboard + +Adds new admin analytics endpoints and UI tables showing membership counts +broken down by company type and revenue tier. Includes CSV export functionality. diff --git a/.changeset/fiery-actors-show.md b/.changeset/fiery-actors-show.md new file mode 100644 index 0000000000..450cd1d6c0 --- /dev/null +++ b/.changeset/fiery-actors-show.md @@ -0,0 +1,4 @@ +--- +--- + +Add unified user counts (members + Slack-only) to accounts list and detail pages. Add Addie tools for listing organizations by user count and listing Slack users by organization. diff --git a/.changeset/fiery-days-roll.md b/.changeset/fiery-days-roll.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fiery-days-roll.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fiery-maps-tan.md b/.changeset/fiery-maps-tan.md new file mode 100644 index 0000000000..79826f3d12 --- /dev/null +++ b/.changeset/fiery-maps-tan.md @@ -0,0 +1,12 @@ +--- +--- + +Add call_adcp_agent tool and Claude Skills for full AdCP protocol access. + +This enables clients to execute the full AdCP spec (not just testing API) via: +- `call_adcp_agent` tool: Low-level proxy to any AdCP-compliant sales agent +- Claude Skills: Protocol knowledge for media-buy, signals, and creative + +Skills teach Claude the protocol schemas and workflows; the tool routes to +whatever agent the user specifies. Auth tokens are looked up from saved +agent context or can be provided directly. diff --git a/.changeset/fifty-bars-pump.md b/.changeset/fifty-bars-pump.md new file mode 100644 index 0000000000..d6ac9145fb --- /dev/null +++ b/.changeset/fifty-bars-pump.md @@ -0,0 +1,4 @@ +--- +--- + +Remove insight_goals table and consolidate goal management into outreach_goals. Admin insight goals CRUD endpoints and UI have been removed. The insight extractor and passive extraction now read from outreach_goals. diff --git a/.changeset/fix-duplicate-members.md b/.changeset/fix-duplicate-members.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fix-duplicate-members.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fix-migration-151.md b/.changeset/fix-migration-151.md new file mode 100644 index 0000000000..4ec327c8b6 --- /dev/null +++ b/.changeset/fix-migration-151.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Fix migration 151 to delete duplicates before updating Slack IDs to WorkOS IDs diff --git a/.changeset/fix-outreach-stats.md b/.changeset/fix-outreach-stats.md new file mode 100644 index 0000000000..8a7bfeefe1 --- /dev/null +++ b/.changeset/fix-outreach-stats.md @@ -0,0 +1,4 @@ +--- +--- + +fix: align frontend outreach stats field names with backend API response diff --git a/.changeset/fix-package-request-fields.md b/.changeset/fix-package-request-fields.md new file mode 100644 index 0000000000..d8b68492b7 --- /dev/null +++ b/.changeset/fix-package-request-fields.md @@ -0,0 +1,22 @@ +--- +"adcontextprotocol": patch +--- + +Add missing fields to package request schemas for consistency with core/package.json. + +**Schema Changes:** + +- `media-buy/package-request.json`: Added `impressions` and `paused` fields +- `media-buy/update-media-buy-request.json`: Added `impressions` field to package updates + +**Details:** + +- `impressions`: Impression goal for the package (optional, minimum: 0) +- `paused`: Create package in paused state (optional, default: false) + +These fields were defined in `core/package.json` but missing from the request schemas, making it impossible to set impression goals or initial paused state when creating/updating media buys. + +**Documentation:** + +- Updated `create_media_buy` task reference with new package parameters +- Updated `update_media_buy` task reference with impressions parameter diff --git a/.changeset/fix-perspectives-link.md b/.changeset/fix-perspectives-link.md new file mode 100644 index 0000000000..6c8028d3d0 --- /dev/null +++ b/.changeset/fix-perspectives-link.md @@ -0,0 +1,4 @@ +--- +--- + +Fix broken working group post links in Slack notifications and frontend diff --git a/.changeset/fix-slack-auth-security.md b/.changeset/fix-slack-auth-security.md new file mode 100644 index 0000000000..dd0727322d --- /dev/null +++ b/.changeset/fix-slack-auth-security.md @@ -0,0 +1,4 @@ +--- +--- + +Update dependencies to address security vulnerabilities. diff --git a/.changeset/fix-slack-leader-display.md b/.changeset/fix-slack-leader-display.md new file mode 100644 index 0000000000..3620cb18ae --- /dev/null +++ b/.changeset/fix-slack-leader-display.md @@ -0,0 +1,12 @@ +--- +"adcontextprotocol": patch +--- + +fix: display Slack profile name for chapter leaders without WorkOS accounts + +Leaders added via Slack ID that haven't linked their WorkOS account now display +their Slack profile name (real_name or display_name) instead of the raw Slack +user ID (e.g., U09BEKNJ3GB). + +The getLeaders and getLeadersBatch queries now include slack_user_mappings as an +additional name source in the COALESCE chain. diff --git a/.changeset/flat-turkeys-tell.md b/.changeset/flat-turkeys-tell.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/flat-turkeys-tell.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/forty-symbols-kick.md b/.changeset/forty-symbols-kick.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/forty-symbols-kick.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/free-terms-send.md b/.changeset/free-terms-send.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/free-terms-send.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fresh-crews-wash.md b/.changeset/fresh-crews-wash.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fresh-crews-wash.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fruity-papayas-act.md b/.changeset/fruity-papayas-act.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fruity-papayas-act.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fuzzy-streets-call.md b/.changeset/fuzzy-streets-call.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fuzzy-streets-call.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/gentle-moose-greet.md b/.changeset/gentle-moose-greet.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/gentle-moose-greet.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/good-kiwis-tie.md b/.changeset/good-kiwis-tie.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/good-kiwis-tie.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/green-cooks-double.md b/.changeset/green-cooks-double.md new file mode 100644 index 0000000000..bed9202737 --- /dev/null +++ b/.changeset/green-cooks-double.md @@ -0,0 +1,4 @@ +--- +--- + +Consolidate check_agent_health and get_agent_capabilities into probe_adcp_agent tool diff --git a/.changeset/hip-bugs-jam.md b/.changeset/hip-bugs-jam.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/hip-bugs-jam.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/hungry-bars-jump.md b/.changeset/hungry-bars-jump.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/hungry-bars-jump.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/industry-gathering-slug-fix.md b/.changeset/industry-gathering-slug-fix.md new file mode 100644 index 0000000000..ec380ec43f --- /dev/null +++ b/.changeset/industry-gathering-slug-fix.md @@ -0,0 +1,3 @@ +--- +--- + diff --git a/.changeset/jolly-houses-shake.md b/.changeset/jolly-houses-shake.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/jolly-houses-shake.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/lemon-cats-brake.md b/.changeset/lemon-cats-brake.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/lemon-cats-brake.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/loud-hairs-repeat.md b/.changeset/loud-hairs-repeat.md new file mode 100644 index 0000000000..76db2d2794 --- /dev/null +++ b/.changeset/loud-hairs-repeat.md @@ -0,0 +1,4 @@ +--- +--- + +Filter RSS and email content from research section to show only member perspectives. diff --git a/.changeset/loud-pigs-heal.md b/.changeset/loud-pigs-heal.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/loud-pigs-heal.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/metal-ravens-argue.md b/.changeset/metal-ravens-argue.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/metal-ravens-argue.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mighty-needles-count.md b/.changeset/mighty-needles-count.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/mighty-needles-count.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/neat-things-lay.md b/.changeset/neat-things-lay.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/neat-things-lay.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/new-coins-think.md b/.changeset/new-coins-think.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/new-coins-think.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/nine-rings-beam.md b/.changeset/nine-rings-beam.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/nine-rings-beam.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/open-rooms-tap.md b/.changeset/open-rooms-tap.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/open-rooms-tap.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/open-squids-flow.md b/.changeset/open-squids-flow.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/open-squids-flow.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/orange-bottles-reply.md b/.changeset/orange-bottles-reply.md new file mode 100644 index 0000000000..741650006f --- /dev/null +++ b/.changeset/orange-bottles-reply.md @@ -0,0 +1,4 @@ +--- +--- + +Clarify AdCP/MCP relationship in docs: AdCP works *over* MCP and A2A as transports, not "built on" MCP. diff --git a/.changeset/polite-candies-rescue.md b/.changeset/polite-candies-rescue.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/polite-candies-rescue.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/polite-candles-walk.md b/.changeset/polite-candles-walk.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/polite-candles-walk.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/polite-socks-swim.md b/.changeset/polite-socks-swim.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/polite-socks-swim.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/release-relaxed-schemas.md b/.changeset/release-relaxed-schemas.md new file mode 100644 index 0000000000..fb0609cfce --- /dev/null +++ b/.changeset/release-relaxed-schemas.md @@ -0,0 +1,9 @@ +--- +"adcontextprotocol": patch +--- + +Release schemas with `additionalProperties: true` for forward compatibility + +This releases `dist/schemas/2.5.2/` containing the relaxed schema validation +introduced in #646. Clients can now safely ignore unknown fields when parsing +API responses, allowing the API to evolve without breaking existing integrations. diff --git a/.changeset/ripe-ghosts-bet.md b/.changeset/ripe-ghosts-bet.md new file mode 100644 index 0000000000..50f3463080 --- /dev/null +++ b/.changeset/ripe-ghosts-bet.md @@ -0,0 +1,9 @@ +--- +--- + +Add escalation and learning capture tools for Addie + +- New `escalate_to_admin` tool for capability gaps and requests needing human action +- New `capture_learning` tool to flag valuable user insights for synthesis +- Admin API endpoints for managing escalations +- System prompt updates to prevent Addie from promising actions without tools diff --git a/.changeset/seven-deer-marry.md b/.changeset/seven-deer-marry.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/seven-deer-marry.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/seven-heads-smile.md b/.changeset/seven-heads-smile.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/seven-heads-smile.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/seven-rules-invent.md b/.changeset/seven-rules-invent.md new file mode 100644 index 0000000000..37a3d4f1bc --- /dev/null +++ b/.changeset/seven-rules-invent.md @@ -0,0 +1,4 @@ +--- +--- + +Allow working group leaders to schedule meetings for groups they lead via Addie. Added meeting and event tools documentation to Addie's system prompt. diff --git a/.changeset/seven-squids-carry.md b/.changeset/seven-squids-carry.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/seven-squids-carry.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/slow-trains-agree.md b/.changeset/slow-trains-agree.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/slow-trains-agree.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/small-geese-leave.md b/.changeset/small-geese-leave.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/small-geese-leave.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/smooth-rice-chew.md b/.changeset/smooth-rice-chew.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/smooth-rice-chew.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/some-phones-study.md b/.changeset/some-phones-study.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/some-phones-study.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/spicy-plants-rescue.md b/.changeset/spicy-plants-rescue.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/spicy-plants-rescue.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/stale-hats-dance.md b/.changeset/stale-hats-dance.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/stale-hats-dance.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/strict-lamps-turn.md b/.changeset/strict-lamps-turn.md new file mode 100644 index 0000000000..aab5a8b79f --- /dev/null +++ b/.changeset/strict-lamps-turn.md @@ -0,0 +1,4 @@ +--- +--- + +Fix Slack user linking in bulk prospect creation from domain discovery. diff --git a/.changeset/tender-experts-repeat.md b/.changeset/tender-experts-repeat.md new file mode 100644 index 0000000000..4ceb3aa470 --- /dev/null +++ b/.changeset/tender-experts-repeat.md @@ -0,0 +1,4 @@ +--- +--- + +Remove insight_goal_id references following migration 166 column drop. diff --git a/.changeset/tender-jeans-double.md b/.changeset/tender-jeans-double.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/tender-jeans-double.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/thin-lions-trade.md b/.changeset/thin-lions-trade.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/thin-lions-trade.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/thirty-snails-kneel.md b/.changeset/thirty-snails-kneel.md new file mode 100644 index 0000000000..efbd790a09 --- /dev/null +++ b/.changeset/thirty-snails-kneel.md @@ -0,0 +1,4 @@ +--- +--- + +Add token-aware context limiting for Addie to prevent prompt overflow errors in long conversations. diff --git a/.changeset/twenty-ads-lay.md b/.changeset/twenty-ads-lay.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/twenty-ads-lay.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/twenty-bobcats-fall.md b/.changeset/twenty-bobcats-fall.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/twenty-bobcats-fall.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/wise-tigers-lose.md b/.changeset/wise-tigers-lose.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/wise-tigers-lose.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.gitignore b/.gitignore index 2a7ee9f89e..d2569fc260 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ coverage/ # Addie external repo cache .addie-repos/ +# Generated skill schemas (built from dist/schemas) +skills/*/schemas/ + # Stripe webhook secret (dynamically generated) .stripe-webhook-secret test-results/ diff --git a/README.md b/README.md index dbb59377ab..8cd09092fe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AdCP - Open Standard for Advertising Automation -**Unified advertising automation protocol built on Model Context Protocol (MCP)** +**Open standard for advertising automation over MCP and A2A protocols** [![GitHub stars](https://img.shields.io/github/stars/adcontextprotocol/adcp?style=social)](https://github.com/adcontextprotocol/adcp) [![Documentation](https://img.shields.io/badge/docs-adcontextprotocol.org-blue)](https://adcontextprotocol.org) @@ -15,7 +15,7 @@ Visit [adcontextprotocol.org](https://adcontextprotocol.org) for full documentat ## What is AdCP? -Ad Context Protocol (AdCP) is an **open standard for advertising automation** that enables AI assistants to interact with advertising platforms through natural language. Built on the Model Context Protocol (MCP), AdCP provides: +Ad Context Protocol (AdCP) is an **open standard for advertising automation** that enables AI assistants to interact with advertising platforms through natural language. AdCP defines domain-specific tasks and schemas that work over MCP and A2A as transports: - πŸ”Œ **Unified Advertising API** - Single interface for all advertising platforms - πŸ€– **AI-Powered Automation** - Built for natural language campaign management diff --git a/dist/schemas/2.5.2/adagents.json b/dist/schemas/2.5.2/adagents.json new file mode 100644 index 0000000000..2babff1b0b --- /dev/null +++ b/dist/schemas/2.5.2/adagents.json @@ -0,0 +1,494 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/2.5.2/adagents.json", + "title": "Authorized Sales Agents", + "description": "Declaration of authorized sales agents for advertising inventory. Hosted at /.well-known/adagents.json on publisher domains. Can either contain the full structure inline or reference an authoritative URL.", + "oneOf": [ + { + "type": "object", + "description": "URL reference variant - points to the authoritative location of the adagents.json file", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema identifier for this adagents.json file" + }, + "authoritative_location": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "description": "HTTPS URL of the authoritative adagents.json file. When present, this file is a reference and the authoritative location contains the actual agent authorization data." + }, + "last_updated": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp indicating when this reference was last updated" + } + }, + "required": [ + "authoritative_location" + ], + "additionalProperties": true + }, + { + "type": "object", + "description": "Inline structure variant - contains full agent authorization data", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema identifier for this adagents.json file" + }, + "contact": { + "type": "object", + "description": "Contact information for the entity managing this adagents.json file (may be publisher or third-party operator)", + "properties": { + "name": { + "type": "string", + "description": "Name of the entity managing this file (e.g., 'Meta Advertising Operations', 'Clear Channel Digital')", + "minLength": 1, + "maxLength": 255 + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email for questions or issues with this authorization file", + "minLength": 1, + "maxLength": 255 + }, + "domain": { + "type": "string", + "description": "Primary domain of the entity managing this file", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "seller_id": { + "type": "string", + "description": "Seller ID from IAB Tech Lab sellers.json (if applicable)", + "minLength": 1, + "maxLength": 255 + }, + "tag_id": { + "type": "string", + "description": "TAG Certified Against Fraud ID for verification (if applicable)", + "minLength": 1, + "maxLength": 100 + } + }, + "required": [ + "name" + ], + "additionalProperties": true + }, + "properties": { + "type": "array", + "description": "Array of all properties covered by this adagents.json file. Defines the canonical property list that authorized agents reference.", + "items": { + "$ref": "/schemas/2.5.2/core/property.json" + }, + "minItems": 1 + }, + "tags": { + "type": "object", + "description": "Metadata for each tag referenced by properties. Provides human-readable context for property tag values.", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for this tag" + }, + "description": { + "type": "string", + "description": "Description of what this tag represents" + } + }, + "required": [ + "name", + "description" + ], + "additionalProperties": true + } + }, + "authorized_agents": { + "type": "array", + "description": "Array of sales agents authorized to sell inventory for properties in this file", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "property_ids", + "description": "Discriminator indicating authorization by specific property IDs" + }, + "property_ids": { + "type": "array", + "description": "Property IDs this agent is authorized for. Resolved against the top-level properties array in this file", + "items": { + "$ref": "/schemas/2.5.2/core/property-id.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "property_ids" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "property_tags", + "description": "Discriminator indicating authorization by property tags" + }, + "property_tags": { + "type": "array", + "description": "Tags identifying which properties this agent is authorized for. Resolved against the top-level properties array in this file using tag matching", + "items": { + "$ref": "/schemas/2.5.2/core/property-tag.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "property_tags" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "inline_properties", + "description": "Discriminator indicating authorization by inline property definitions" + }, + "properties": { + "type": "array", + "description": "Specific properties this agent is authorized for (alternative to property_ids/property_tags)", + "items": { + "$ref": "/schemas/2.5.2/core/property.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "properties" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The authorized agent's API endpoint URL" + }, + "authorized_for": { + "type": "string", + "description": "Human-readable description of what this agent is authorized to sell", + "minLength": 1, + "maxLength": 500 + }, + "authorization_type": { + "type": "string", + "const": "publisher_properties", + "description": "Discriminator indicating authorization for properties from other publisher domains" + }, + "publisher_properties": { + "type": "array", + "description": "Properties from other publisher domains this agent is authorized for. Each entry specifies a publisher domain and which of their properties this agent can sell", + "items": { + "$ref": "/schemas/2.5.2/core/publisher-property-selector.json" + }, + "minItems": 1 + } + }, + "required": [ + "url", + "authorized_for", + "authorization_type", + "publisher_properties" + ], + "additionalProperties": true + } + ] + }, + "minItems": 1 + }, + "last_updated": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp indicating when this file was last updated" + } + }, + "required": [ + "authorized_agents" + ], + "additionalProperties": true + } + ], + "examples": [ + { + "$schema": "/schemas/2.5.2/adagents.json", + "authoritative_location": "https://cdn.example.com/adagents/v2/adagents.json", + "last_updated": "2025-01-15T10:00:00Z" + }, + { + "$schema": "/schemas/2.5.2/adagents.json", + "properties": [ + { + "property_type": "website", + "name": "Example Site", + "identifiers": [ + { + "type": "domain", + "value": "example.com" + } + ], + "publisher_domain": "example.com" + } + ], + "authorized_agents": [ + { + "url": "https://agent.example.com", + "authorized_for": "Official sales agent", + "authorization_type": "property_tags", + "property_tags": [ + "all" + ] + } + ], + "tags": { + "all": { + "name": "All Properties", + "description": "All properties in this file" + } + }, + "last_updated": "2025-01-10T12:00:00Z" + }, + { + "$schema": "/schemas/2.5.2/adagents.json", + "contact": { + "name": "Meta Advertising Operations", + "email": "adops@meta.com", + "domain": "meta.com", + "seller_id": "pub-meta-12345", + "tag_id": "12345" + }, + "properties": [ + { + "property_type": "mobile_app", + "name": "Instagram", + "identifiers": [ + { + "type": "ios_bundle", + "value": "com.burbn.instagram" + }, + { + "type": "android_package", + "value": "com.instagram.android" + } + ], + "tags": [ + "meta_network", + "social_media" + ], + "publisher_domain": "instagram.com" + }, + { + "property_type": "mobile_app", + "name": "Facebook", + "identifiers": [ + { + "type": "ios_bundle", + "value": "com.facebook.Facebook" + }, + { + "type": "android_package", + "value": "com.facebook.katana" + } + ], + "tags": [ + "meta_network", + "social_media" + ], + "publisher_domain": "facebook.com" + }, + { + "property_type": "mobile_app", + "name": "WhatsApp", + "identifiers": [ + { + "type": "ios_bundle", + "value": "net.whatsapp.WhatsApp" + }, + { + "type": "android_package", + "value": "com.whatsapp" + } + ], + "tags": [ + "meta_network", + "messaging" + ], + "publisher_domain": "whatsapp.com" + } + ], + "tags": { + "meta_network": { + "name": "Meta Network", + "description": "All Meta-owned properties" + }, + "social_media": { + "name": "Social Media Apps", + "description": "Social networking applications" + }, + "messaging": { + "name": "Messaging Apps", + "description": "Messaging and communication apps" + } + }, + "authorized_agents": [ + { + "url": "https://meta-ads.com", + "authorized_for": "All Meta properties", + "authorization_type": "property_tags", + "property_tags": [ + "meta_network" + ] + } + ], + "last_updated": "2025-01-10T15:30:00Z" + }, + { + "$schema": "/schemas/2.5.2/adagents.json", + "contact": { + "name": "Tumblr Advertising" + }, + "properties": [ + { + "property_type": "website", + "name": "Tumblr Corporate", + "identifiers": [ + { + "type": "domain", + "value": "tumblr.com" + } + ], + "tags": [ + "corporate" + ], + "publisher_domain": "tumblr.com" + } + ], + "tags": { + "corporate": { + "name": "Corporate Properties", + "description": "Tumblr-owned corporate properties (not user blogs)" + } + }, + "authorized_agents": [ + { + "url": "https://tumblr-sales.com", + "authorized_for": "Tumblr corporate properties only", + "authorization_type": "property_tags", + "property_tags": [ + "corporate" + ] + } + ], + "last_updated": "2025-01-10T16:00:00Z" + }, + { + "$schema": "/schemas/2.5.2/adagents.json", + "contact": { + "name": "Example Third-Party Sales Agent", + "email": "sales@agent.example", + "domain": "agent.example" + }, + "authorized_agents": [ + { + "url": "https://agent.example/api", + "authorized_for": "CNN CTV properties via publisher authorization", + "authorization_type": "publisher_properties", + "publisher_properties": [ + { + "publisher_domain": "cnn.com", + "selection_type": "by_id", + "property_ids": [ + "cnn_ctv_app" + ] + } + ] + }, + { + "url": "https://agent.example/api", + "authorized_for": "All CTV properties from multiple publishers", + "authorization_type": "publisher_properties", + "publisher_properties": [ + { + "publisher_domain": "cnn.com", + "selection_type": "by_tag", + "property_tags": [ + "ctv" + ] + }, + { + "publisher_domain": "espn.com", + "selection_type": "by_tag", + "property_tags": [ + "ctv" + ] + } + ] + } + ], + "last_updated": "2025-01-10T17:00:00Z" + } + ] +} diff --git a/dist/schemas/2.5.2/bundled/media-buy/build-creative-request.json b/dist/schemas/2.5.2/bundled/media-buy/build-creative-request.json new file mode 100644 index 0000000000..794531b8ec --- /dev/null +++ b/dist/schemas/2.5.2/bundled/media-buy/build-creative-request.json @@ -0,0 +1,1349 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/2.5.2/bundled/media-buy/build-creative-request.json", + "title": "Build Creative Request", + "description": "Request to transform or generate a creative manifest. Takes a source manifest (which may be minimal for pure generation) and produces a target manifest in the specified format. The source manifest should include all assets required by the target format (e.g., promoted_offerings for generative formats).", + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Natural language instructions for the transformation or generation. For pure generation, this is the creative brief. For transformation, this provides guidance on how to adapt the creative." + }, + "creative_manifest": { + "title": "Creative Manifest", + "description": "Creative manifest to transform or generate from. For pure generation, this should include the target format_id and any required input assets (e.g., promoted_offerings for generative formats). For transformation (e.g., resizing, reformatting), this is the complete creative to adapt.", + "type": "object", + "properties": { + "format_id": { + "title": "Format ID", + "description": "Format identifier this manifest is for. Can be a template format (id only) or a deterministic format (id + dimensions/duration). For dimension-specific creatives, include width/height/unit in the format_id to create a unique identifier (e.g., {id: 'display_static', width: 300, height: 250, unit: 'px'}).", + "type": "object", + "properties": { + "agent_url": { + "type": "string", + "format": "uri", + "description": "URL of the agent that defines this format (e.g., 'https://creatives.adcontextprotocol.org' for standard formats, or 'https://publisher.com/.well-known/adcp/sales' for custom formats)" + }, + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "Format identifier within the agent's namespace (e.g., 'display_static', 'video_hosted', 'audio_standard'). When used alone, references a template format. When combined with dimension/duration fields, creates a parameterized format ID for a specific variant." + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Width in pixels for visual formats. When specified, height must also be specified. Both fields together create a parameterized format ID for dimension-specific variants." + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Height in pixels for visual formats. When specified, width must also be specified. Both fields together create a parameterized format ID for dimension-specific variants." + }, + "duration_ms": { + "type": "number", + "minimum": 1, + "description": "Duration in milliseconds for time-based formats (video, audio). When specified, creates a parameterized format ID. Omit to reference a template format without parameters." + } + }, + "required": [ + "agent_url", + "id" + ], + "additionalProperties": true, + "dependencies": { + "width": [ + "height" + ], + "height": [ + "width" + ] + } + }, + "promoted_offering": { + "type": "string", + "description": "Product name or offering being advertised. Maps to promoted_offerings in create_media_buy request to associate creative with the product being promoted." + }, + "assets": { + "type": "object", + "description": "Map of asset IDs to actual asset content. Each key MUST match an asset_id from the format's assets_required array (e.g., 'banner_image', 'clickthrough_url', 'video_file', 'vast_tag'). The asset_id is the technical identifier used to match assets to format requirements.\n\nIMPORTANT: Creative manifest validation MUST be performed in the context of the format specification. The format defines what type each asset_id should be, which eliminates any validation ambiguity.", + "patternProperties": { + "^[a-z0-9_]+$": { + "oneOf": [ + { + "title": "Image Asset", + "description": "Image asset with URL and dimensions", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to the image asset" + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Width in pixels" + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Height in pixels" + }, + "format": { + "type": "string", + "description": "Image file format (jpg, png, gif, webp, etc.)" + }, + "alt_text": { + "type": "string", + "description": "Alternative text for accessibility" + } + }, + "required": [ + "url", + "width", + "height" + ], + "additionalProperties": true + }, + { + "title": "Video Asset", + "description": "Video asset with URL and specifications", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to the video asset" + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Width in pixels" + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Height in pixels" + }, + "duration_ms": { + "type": "integer", + "description": "Video duration in milliseconds", + "minimum": 1 + }, + "format": { + "type": "string", + "description": "Video file format (mp4, webm, mov, etc.)" + }, + "bitrate_kbps": { + "type": "integer", + "description": "Video bitrate in kilobits per second", + "minimum": 1 + } + }, + "required": [ + "url", + "width", + "height" + ], + "additionalProperties": true + }, + { + "title": "Audio Asset", + "description": "Audio asset with URL and specifications", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to the audio asset" + }, + "duration_ms": { + "type": "integer", + "description": "Audio duration in milliseconds", + "minimum": 0 + }, + "format": { + "type": "string", + "description": "Audio file format (mp3, wav, aac, etc.)" + }, + "bitrate_kbps": { + "type": "integer", + "description": "Audio bitrate in kilobits per second", + "minimum": 1 + } + }, + "required": [ + "url" + ], + "additionalProperties": true + }, + { + "title": "VAST Asset", + "description": "VAST (Video Ad Serving Template) tag for third-party video ad serving", + "oneOf": [ + { + "type": "object", + "properties": { + "delivery_type": { + "type": "string", + "const": "url", + "description": "Discriminator indicating VAST is delivered via URL endpoint" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL endpoint that returns VAST XML" + }, + "vast_version": { + "title": "VAST Version", + "description": "VAST specification version", + "type": "string", + "enum": [ + "2.0", + "3.0", + "4.0", + "4.1", + "4.2" + ] + }, + "vpaid_enabled": { + "type": "boolean", + "description": "Whether VPAID (Video Player-Ad Interface Definition) is supported" + }, + "duration_ms": { + "type": "integer", + "description": "Expected video duration in milliseconds (if known)", + "minimum": 0 + }, + "tracking_events": { + "type": "array", + "items": { + "title": "VAST Tracking Event", + "description": "Standard VAST tracking events for video ad playback and interaction", + "type": "string", + "enum": [ + "start", + "firstQuartile", + "midpoint", + "thirdQuartile", + "complete", + "impression", + "click", + "pause", + "resume", + "skip", + "mute", + "unmute", + "fullscreen", + "exitFullscreen", + "playerExpand", + "playerCollapse" + ] + }, + "description": "Tracking events supported by this VAST tag" + } + }, + "required": [ + "delivery_type", + "url" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "delivery_type": { + "type": "string", + "const": "inline", + "description": "Discriminator indicating VAST is delivered as inline XML content" + }, + "content": { + "type": "string", + "description": "Inline VAST XML content" + }, + "vast_version": { + "title": "VAST Version", + "description": "VAST specification version", + "type": "string", + "enum": [ + "2.0", + "3.0", + "4.0", + "4.1", + "4.2" + ] + }, + "vpaid_enabled": { + "type": "boolean", + "description": "Whether VPAID (Video Player-Ad Interface Definition) is supported" + }, + "duration_ms": { + "type": "integer", + "description": "Expected video duration in milliseconds (if known)", + "minimum": 0 + }, + "tracking_events": { + "type": "array", + "items": { + "title": "VAST Tracking Event", + "description": "Standard VAST tracking events for video ad playback and interaction", + "type": "string", + "enum": [ + "start", + "firstQuartile", + "midpoint", + "thirdQuartile", + "complete", + "impression", + "click", + "pause", + "resume", + "skip", + "mute", + "unmute", + "fullscreen", + "exitFullscreen", + "playerExpand", + "playerCollapse" + ] + }, + "description": "Tracking events supported by this VAST tag" + } + }, + "required": [ + "delivery_type", + "content" + ], + "additionalProperties": true + } + ] + }, + { + "title": "Text Asset", + "description": "Text content asset", + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Text content" + }, + "language": { + "type": "string", + "description": "Language code (e.g., 'en', 'es', 'fr')" + } + }, + "required": [ + "content" + ], + "additionalProperties": true + }, + { + "title": "URL Asset", + "description": "URL reference asset", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL reference" + }, + "url_type": { + "title": "URL Asset Type", + "description": "Type of URL asset: 'clickthrough' for user click destination (landing page), 'tracker_pixel' for impression/event tracking via HTTP request (fires GET, expects pixel/204 response), 'tracker_script' for measurement SDKs that must load as diff --git a/server/public/admin-addie.html b/server/public/admin-addie.html index 8ac3d24e60..1cbf9a2f92 100644 --- a/server/public/admin-addie.html +++ b/server/public/admin-addie.html @@ -1237,6 +1237,22 @@

Addie Dashboard

Manage Addie's knowledge base, view interactions, and review pending actions.

+ +
+
+ Config Version: + - + (- rules) +
+
+ + - messages | + - avg rating + + +
+
+
@@ -1265,6 +1281,7 @@

Addie Dashboard

+ @@ -1321,6 +1338,158 @@

Loading...

+ +
+
+
+
+

Insight Synthesis

+

+ Tag expert content to synthesize into Addie's core knowledge +

+
+
+ + + +
+
+ + +
+

Pending by Topic

+
+
Loading...
+
+
+ + +
+

Pending Insights

+
+
Loading...
+
+
+ + +
+

Recent Synthesis Runs

+
+
Loading...
+
+
+
+
+ + + + + + + + + + + + +
@@ -1434,6 +1603,7 @@

Conversation Threads

+
@@ -1936,15 +2106,17 @@

Add Knowledge

// Load stats async function loadStats() { try { - const [knowledgeRes, threadsRes, queueRes] = await Promise.all([ + const [knowledgeRes, threadsRes, queueRes, configRes] = await Promise.all([ fetch('/api/admin/addie/knowledge/categories'), fetch('/api/admin/addie/threads/stats'), - fetch('/api/admin/addie/queue/stats') + fetch('/api/admin/addie/queue/stats'), + fetch('/api/admin/addie/config/current') ]); const knowledge = await knowledgeRes.json(); const threads = await threadsRes.json(); const queue = await queueRes.json(); + const config = await configRes.json(); const totalKnowledge = knowledge.categories?.reduce((sum, c) => sum + c.count, 0) || 0; document.getElementById('stat-knowledge').textContent = totalKnowledge; @@ -1952,11 +2124,70 @@

Add Knowledge

document.getElementById('stat-messages').textContent = threads.messages_30d || 0; document.getElementById('stat-flagged').textContent = threads.flagged_threads || 0; document.getElementById('stat-pending').textContent = queue.pending || 0; + + // Update config version banner + if (config.config_version) { + const cv = config.config_version; + document.getElementById('config-version-id').textContent = `v${cv.version_id}`; + document.getElementById('config-rule-count').textContent = cv.rule_count || 0; + document.getElementById('config-message-count').textContent = cv.message_count || 0; + document.getElementById('config-rating').textContent = cv.avg_rating + ? `${parseFloat(cv.avg_rating).toFixed(1)}/5` + : 'N/A'; + } } catch (error) { console.error('Error loading stats:', error); } } + // Config version history modal + async function showConfigHistory() { + const modal = document.getElementById('config-history-modal'); + modal.style.display = 'flex'; + + try { + const res = await fetch('/api/admin/addie/config/history?limit=20'); + const data = await res.json(); + + const listEl = document.getElementById('config-history-list'); + if (!data.versions || data.versions.length === 0) { + listEl.innerHTML = 'No config versions found'; + return; + } + + listEl.innerHTML = data.versions.map(v => ` + + + v${v.version_id} +
+ ${v.config_hash?.substring(0, 8) || '-'} + + + ${new Date(v.created_at).toLocaleDateString()}
+ ${new Date(v.created_at).toLocaleTimeString()} + + ${v.rule_count || 0} + ${v.message_count || 0} + + ${v.avg_rating ? `${parseFloat(v.avg_rating).toFixed(1)}` : '-'} + ${v.positive_feedback || v.negative_feedback ? `
+${v.positive_feedback || 0}/-${v.negative_feedback || 0}` : ''} + + + ${v.source_synthesis_run_ids?.length ? `Synthesis #${v.source_synthesis_run_ids.join(', #')}` : 'Manual'} + + + `).join(''); + } catch (error) { + console.error('Error loading config history:', error); + document.getElementById('config-history-list').innerHTML = + 'Error loading history'; + } + } + + function closeConfigHistoryModal() { + document.getElementById('config-history-modal').style.display = 'none'; + } + // Load unified knowledge list (docs, curated resources, slack messages) async function loadKnowledge() { const sourceType = document.getElementById('source-type-filter').value; @@ -2209,6 +2440,7 @@

${escapeHtml(doc.title)}

const flaggedOnly = document.getElementById('threads-flagged-only').checked; const unreviewedOnly = document.getElementById('threads-unreviewed-only').checked; const hasUserFeedback = document.getElementById('threads-has-feedback').checked; + const multiTurnOnly = document.getElementById('threads-multi-turn').checked; const timeframe = document.getElementById('stats-timeframe').value; const params = new URLSearchParams(); @@ -2217,6 +2449,7 @@

${escapeHtml(doc.title)}

if (flaggedOnly) params.append('flagged_only', 'true'); if (unreviewedOnly) params.append('unreviewed_only', 'true'); if (hasUserFeedback) params.append('has_user_feedback', 'true'); + if (multiTurnOnly) params.append('min_messages', '2'); // Add timeframe filter to threads query if (timeframe !== 'all') { @@ -2584,16 +2817,24 @@

${escapeHtml(doc.title)}

// Mark thread as reviewed async function markThreadReviewed(threadId) { try { - await fetch(`/api/admin/addie/threads/${threadId}/review`, { + const response = await fetch(`/api/admin/addie/threads/${threadId}/review`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes: '' }) }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } loadThreads(); loadStats(); + // Refresh the thread detail view if it's currently open + if (currentThreadData?.thread_id === threadId) { + await viewThread(threadId); + } } catch (error) { console.error('Error marking reviewed:', error); - alert('Error marking thread as reviewed'); + alert('Error marking thread as reviewed: ' + error.message); } } @@ -2602,55 +2843,79 @@

${escapeHtml(doc.title)}

const reason = prompt('Reason for flagging (optional):'); if (reason === null) return; // User cancelled try { - await fetch(`/api/admin/addie/threads/${threadId}/flag`, { + const response = await fetch(`/api/admin/addie/threads/${threadId}/flag`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } loadThreads(); loadStats(); + // Refresh the thread detail view if it's currently open + if (currentThreadData?.thread_id === threadId) { + await viewThread(threadId); + } } catch (error) { console.error('Error flagging thread:', error); - alert('Error flagging thread'); + alert('Error flagging thread: ' + error.message); } } async function unflagThread(threadId) { try { - await fetch(`/api/admin/addie/threads/${threadId}/unflag`, { method: 'PUT' }); + const response = await fetch(`/api/admin/addie/threads/${threadId}/unflag`, { method: 'PUT' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } loadThreads(); loadStats(); + // Refresh the thread detail view if it's currently open + if (currentThreadData?.thread_id === threadId) { + await viewThread(threadId); + } } catch (error) { console.error('Error unflagging thread:', error); - alert('Error unflagging thread'); + alert('Error unflagging thread: ' + error.message); } } // Approve/reject queue items async function approveItem(id) { try { - await fetch(`/api/admin/addie/queue/${id}/approve`, { method: 'PUT' }); + const response = await fetch(`/api/admin/addie/queue/${id}/approve`, { method: 'PUT' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } loadQueue(); loadStats(); } catch (error) { console.error('Error approving item:', error); - alert('Error approving item'); + alert('Error approving item: ' + error.message); } } async function rejectItem(id) { const reason = prompt('Reason for rejection (optional):'); try { - await fetch(`/api/admin/addie/queue/${id}/reject`, { + const response = await fetch(`/api/admin/addie/queue/${id}/reject`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } loadQueue(); loadStats(); } catch (error) { console.error('Error rejecting item:', error); - alert('Error rejecting item'); + alert('Error rejecting item: ' + error.message); } } @@ -2670,6 +2935,33 @@

${escapeHtml(doc.title)}

setTimeout(() => el.textContent = original, 1500); } + function copyThreadLink(threadId, buttonEl) { + const url = `${window.location.origin}/admin/addie/?thread=${encodeURIComponent(threadId)}`; + navigator.clipboard.writeText(url).then(() => { + const original = buttonEl.textContent; + buttonEl.textContent = 'Copied!'; + setTimeout(() => buttonEl.textContent = original, 1500); + }).catch(() => { + buttonEl.textContent = 'Failed'; + setTimeout(() => buttonEl.textContent = 'Copy Link', 1500); + }); + } + + function getSlackPermalink(externalId) { + // external_id format for Slack: "channel_id:thread_ts" + if (!externalId || !externalId.includes(':')) return null; + const [channelId, threadTs] = externalId.split(':'); + if (!channelId || !threadTs) return null; + // Validate channelId matches expected Slack channel ID pattern + if (!/^[A-Z][A-Z0-9]{8,}$/.test(channelId)) return null; + // Validate threadTs is a valid timestamp format (digits.digits) + if (!/^\d+\.\d+$/.test(threadTs)) return null; + // Convert thread_ts to Slack's permalink format (remove the dot) + const tsForLink = threadTs.replace('.', ''); + // Using the hardcoded workspace - this is the AAO Slack workspace + return `https://agenticads.slack.com/archives/${channelId}/p${tsForLink}`; + } + // Event listeners document.getElementById('source-type-filter').addEventListener('change', loadKnowledge); document.getElementById('category-filter').addEventListener('change', loadKnowledge); @@ -3120,6 +3412,7 @@

Pattern Identified

let currentThreadData = null; let currentConversationData = null; let extractedLinks = []; + let currentUserNames = {}; // Map of Slack user IDs to display names async function viewThread(threadId) { try { @@ -3127,6 +3420,7 @@

Pattern Identified

const data = await response.json(); currentThreadData = data; currentConversationData = data; + currentUserNames = data.user_names || {}; const channelLabel = data.channel.charAt(0).toUpperCase() + data.channel.slice(1); document.getElementById('conversation-modal-title').textContent = @@ -3145,8 +3439,10 @@

Pattern Identified

${data.message_count} messages β€’ Started ${new Date(data.started_at).toLocaleString()}
-
+
${escapeHtml(data.thread_id)} + + ${(() => { const slackLink = data.channel === 'slack' && data.external_id ? getSlackPermalink(data.external_id) : null; return slackLink ? `Open in Slack` : ''; })()} ${!data.reviewed ? `` : ''} ${!data.flagged ? `` : ``}
@@ -3443,6 +3739,13 @@

Pattern Identified

} function renderMarkdown(text) { + // Replace Slack user mentions with display names before escaping HTML + // Format: <@U1234567> -> @DisplayName + text = text.replace(/<@(U[A-Z0-9]+)>/g, (match, userId) => { + const displayName = currentUserNames[userId]; + return displayName ? `@${displayName}` : match; + }); + // Escape HTML first text = text.replace(/&/g, '&').replace(//g, '>'); @@ -3978,20 +4281,541 @@

Pattern Identified

} } + // ========================================================================= + // INSIGHTS FUNCTIONS + // ========================================================================= + + let currentSynthesisRun = null; + + async function loadInsights() { + try { + const response = await fetch('/api/admin/addie/insights'); + const data = await response.json(); + + // Render topic stats + const topicStats = document.getElementById('topic-stats'); + if (data.topics && data.topics.length > 0) { + topicStats.innerHTML = data.topics.map(topic => ` +
+
${data.sources.filter(s => (s.topic || 'uncategorized') === topic).length}
+
${escapeHtml(topic)}
+
+ `).join('') + ` +
+
${data.pendingCount}
+
Total Pending
+
+ `; + } else { + topicStats.innerHTML = ` +
+
0
+
No pending insights
+
+ `; + } + + // Render insights list + const list = document.getElementById('insights-list'); + if (!data.sources || data.sources.length === 0) { + list.innerHTML = '

No pending insights

Use "Add Insight" to tag expert content for synthesis.

'; + } else { + list.innerHTML = data.sources.map(s => ` +
+
+
+ ${s.topic ? `${escapeHtml(s.topic)}` : 'uncategorized'} + ${s.author_name ? `by ${escapeHtml(s.author_name)}` : ''} +
+
+ ${new Date(s.tagged_at).toLocaleDateString()} + +
+
+
${escapeHtml(s.excerpt || s.content.substring(0, 300))}
+ ${s.notes ? `
Note: ${escapeHtml(s.notes)}
` : ''} +
+ `).join(''); + } + + // Load synthesis runs + await loadSynthesisRuns(); + } catch (error) { + console.error('Error loading insights:', error); + document.getElementById('insights-list').innerHTML = '

Error loading insights

'; + } + } + + async function loadSynthesisRuns() { + try { + const response = await fetch('/api/admin/addie/synthesis?limit=10'); + const data = await response.json(); + + const list = document.getElementById('synthesis-runs-list'); + if (!data.runs || data.runs.length === 0) { + list.innerHTML = '

No synthesis runs yet

Click "Run Synthesis" to process pending insights.

'; + return; + } + + list.innerHTML = data.runs.map(run => { + const statusColors = { + draft: 'var(--color-warning)', + approved: 'var(--color-success)', + applied: 'var(--color-brand)', + rejected: 'var(--color-danger)' + }; + return ` +
+
+
+ ${run.status} + ${run.sources_count} sources β†’ ${run.proposed_rules?.length || 0} rules +
+ ${new Date(run.created_at).toLocaleString()} +
+ ${run.preview_summary ? `
${escapeHtml(run.preview_summary)}
` : ''} + ${run.topics_included?.length > 0 ? `
Topics: ${run.topics_included.map(t => `${escapeHtml(t)}`).join('')}
` : ''} +
+ `; + }).join(''); + } catch (error) { + console.error('Error loading synthesis runs:', error); + } + } + + function openAddInsightModal() { + document.getElementById('add-insight-modal').style.display = 'flex'; + document.getElementById('add-insight-form').reset(); + } + + function closeInsightModal() { + document.getElementById('add-insight-modal').style.display = 'none'; + } + + // Perspective tagging modal + async function openPerspectiveTagModal() { + const modal = document.getElementById('perspective-tag-modal'); + modal.style.display = 'flex'; + document.getElementById('perspective-topic').value = ''; + + const listEl = document.getElementById('perspectives-list'); + listEl.innerHTML = '
Loading perspectives...
'; + + try { + const response = await fetch('/api/admin/addie/insights/perspectives'); + const data = await response.json(); + + if (!data.perspectives || data.perspectives.length === 0) { + listEl.innerHTML = '
No published perspectives found
'; + return; + } + + listEl.innerHTML = data.perspectives.map(p => ` +
+
+
+
${escapeHtml(p.title)}
+ ${p.author_name ? `
by ${escapeHtml(p.author_name)}
` : ''} + ${p.category ? `${escapeHtml(p.category)}` : ''} + ${p.excerpt ? `
${escapeHtml(p.excerpt.substring(0, 150))}...
` : ''} +
+
+ ${p.already_tagged + ? 'Already tagged' + : `` + } +
+
+
+ `).join(''); + } catch (error) { + console.error('Error loading perspectives:', error); + listEl.innerHTML = '
Error loading perspectives
'; + } + } + + function closePerspectiveTagModal() { + document.getElementById('perspective-tag-modal').style.display = 'none'; + } + + async function tagPerspective(perspectiveId, title) { + const topicOverride = document.getElementById('perspective-topic').value.trim(); + + try { + const response = await fetch('/api/admin/addie/insights/from-perspective', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + perspectiveId, + topic: topicOverride || undefined + }) + }); + + if (response.ok) { + alert(`Tagged: ${title}`); + closePerspectiveTagModal(); + loadInsights(); + } else { + const error = await response.json(); + alert('Error: ' + (error.message || 'Failed to tag perspective')); + } + } catch (error) { + console.error('Error tagging perspective:', error); + alert('Error tagging perspective'); + } + } + + document.getElementById('add-insight-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const data = { + source_type: 'external', + content: formData.get('content'), + topic: formData.get('topic') || undefined, + author_name: formData.get('author_name') || undefined, + author_context: formData.get('author_context') || undefined, + notes: formData.get('notes') || undefined + }; + + try { + const response = await fetch('/api/admin/addie/insights', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + closeInsightModal(); + loadInsights(); + alert('Insight added successfully!'); + } else { + const error = await response.json(); + alert('Error: ' + (error.message || 'Failed to add insight')); + } + } catch (error) { + console.error('Error adding insight:', error); + alert('Error adding insight'); + } + }); + + async function archiveInsight(id) { + if (!confirm('Archive this insight? It will no longer be included in synthesis.')) return; + + try { + const response = await fetch(`/api/admin/addie/insights/${id}`, { method: 'DELETE' }); + if (response.ok) { + loadInsights(); + } else { + alert('Error archiving insight'); + } + } catch (error) { + console.error('Error archiving insight:', error); + alert('Error archiving insight'); + } + } + + async function runInsightSynthesis() { + const btn = document.getElementById('run-synthesis-btn'); + btn.disabled = true; + btn.textContent = 'Running...'; + + try { + const response = await fetch('/api/admin/addie/synthesis/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const data = await response.json(); + + if (response.ok) { + loadInsights(); + viewSynthesisRun(data.run.id); + } else { + alert('Error: ' + (data.message || 'Synthesis failed')); + } + } catch (error) { + console.error('Error running synthesis:', error); + alert('Error running synthesis'); + } finally { + btn.disabled = false; + btn.textContent = 'Run Synthesis'; + } + } + + async function viewSynthesisRun(id) { + try { + // Fetch synthesis run AND current rules in parallel + const [synthesisRes, rulesRes, configRes] = await Promise.all([ + fetch(`/api/admin/addie/synthesis/${id}`), + fetch('/api/admin/addie/rules?active_only=true'), + fetch('/api/admin/addie/config/current') + ]); + + const data = await synthesisRes.json(); + const rulesData = await rulesRes.json(); + const configData = await configRes.json(); + + currentSynthesisRun = data.run; + const currentRules = rulesData.rules || []; + const currentKnowledgeRules = currentRules.filter(r => r.rule_type === 'knowledge'); + const configVersion = configData.config_version; + + const content = document.getElementById('synthesis-review-content'); + const run = data.run; + + // Summary stats + let html = ` +
+
+
+
${run.sources_count}
+
Sources
+
+
+
${run.proposed_rules?.length || 0}
+
Rules Proposed
+
+
+
${run.preview_results?.summary?.likely_improved || 0}
+
Likely Improved
+
+
+
${run.preview_results?.summary?.likely_worse || 0}
+
Potentially Worse
+
+
+
+ `; + + if (run.preview_summary) { + html += `
+ Preview Summary: ${escapeHtml(run.preview_summary)} +
`; + } + + // SIDE BY SIDE COMPARISON + html += ` +
+ +
+

+ + Current Knowledge Rules + + (v${configVersion?.version_id || '?'}) + +

+
+ ${currentKnowledgeRules.length === 0 + ? '

No knowledge rules yet

' + : currentKnowledgeRules.map(rule => ` +
+
${escapeHtml(rule.name)}
+
${escapeHtml(rule.content.substring(0, 300))}${rule.content.length > 300 ? '...' : ''}
+
+ `).join('') + } +
+
+ ${currentKnowledgeRules.length} knowledge rule${currentKnowledgeRules.length !== 1 ? 's' : ''} | + ${currentRules.length} total rules +
+
+ + +
+

+ + After This Synthesis +

+
+ ${(run.proposed_rules || []).length === 0 + ? '

No new rules proposed

' + : ` +
+ + ${run.proposed_rules.length} NEW RULE${run.proposed_rules.length !== 1 ? 'S' : ''} +
+ ${run.proposed_rules.map(rule => ` +
+
+ + ${escapeHtml(rule.name)} + ${Math.round(rule.confidence * 100)}% +
+
${escapeHtml(rule.content.substring(0, 300))}${rule.content.length > 300 ? '...' : ''}
+
+ `).join('')} + ` + } +
+
+ ${currentKnowledgeRules.length + (run.proposed_rules?.length || 0)} knowledge rules after | + ${currentRules.length + (run.proposed_rules?.length || 0)} total rules +
+
+
+ `; + + // Source Materials (collapsible) + if (data.sources && data.sources.length > 0) { + html += ` +
+ + Source Materials (${data.sources.length}) + +
+ `; + for (const source of data.sources.slice(0, 5)) { + html += ` +
+ ${source.topic ? `${escapeHtml(source.topic)}` : ''} + ${source.author_name ? `by ${escapeHtml(source.author_name)}` : ''} +
${escapeHtml(source.excerpt || source.content.substring(0, 200))}
+
+ `; + } + if (data.sources.length > 5) { + html += `

...and ${data.sources.length - 5} more sources

`; + } + html += `
`; + } + + content.innerHTML = html; + + // Update action buttons based on status + const approveBtn = document.getElementById('approve-synthesis-btn'); + const rejectBtn = document.getElementById('reject-synthesis-btn'); + const applyBtn = document.getElementById('apply-synthesis-btn'); + + if (run.status === 'draft') { + approveBtn.style.display = ''; + rejectBtn.style.display = ''; + applyBtn.style.display = 'none'; + } else if (run.status === 'approved') { + approveBtn.style.display = 'none'; + rejectBtn.style.display = 'none'; + applyBtn.style.display = ''; + } else { + approveBtn.style.display = 'none'; + rejectBtn.style.display = 'none'; + applyBtn.style.display = 'none'; + } + + document.getElementById('synthesis-review-modal').style.display = 'flex'; + } catch (error) { + console.error('Error loading synthesis run:', error); + alert('Error loading synthesis run'); + } + } + + function closeSynthesisModal() { + document.getElementById('synthesis-review-modal').style.display = 'none'; + currentSynthesisRun = null; + } + + async function approveSynthesis() { + if (!currentSynthesisRun) return; + + try { + const response = await fetch(`/api/admin/addie/synthesis/${currentSynthesisRun.id}/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'approved' }) + }); + + if (response.ok) { + viewSynthesisRun(currentSynthesisRun.id); + loadInsights(); + } else { + alert('Error approving synthesis'); + } + } catch (error) { + console.error('Error approving synthesis:', error); + alert('Error approving synthesis'); + } + } + + async function rejectSynthesis() { + if (!currentSynthesisRun) return; + const notes = prompt('Reason for rejection (optional):'); + + try { + const response = await fetch(`/api/admin/addie/synthesis/${currentSynthesisRun.id}/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'rejected', notes }) + }); + + if (response.ok) { + closeSynthesisModal(); + loadInsights(); + } else { + alert('Error rejecting synthesis'); + } + } catch (error) { + console.error('Error rejecting synthesis:', error); + alert('Error rejecting synthesis'); + } + } + + async function applySynthesis() { + if (!currentSynthesisRun) return; + if (!confirm('Apply these rules to Addie? This will create new knowledge rules.')) return; + + try { + const response = await fetch(`/api/admin/addie/synthesis/${currentSynthesisRun.id}/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (response.ok) { + const data = await response.json(); + const versionMsg = data.configVersionId ? ` Addie is now on config version v${data.configVersionId}.` : ''; + alert(`Success! ${data.rules.length} rules created.${versionMsg}`); + closeSynthesisModal(); + loadInsights(); + loadRules(); + loadStats(); // Refresh config version banner + } else { + const error = await response.json(); + alert('Error: ' + (error.message || 'Failed to apply synthesis')); + } + } catch (error) { + console.error('Error applying synthesis:', error); + alert('Error applying synthesis'); + } + } + + function filterInsightsByTopic(topic) { + // For now, just scroll to the insights list - could add filtering later + document.getElementById('insights-list-container').scrollIntoView({ behavior: 'smooth' }); + } + // Initial load loadStats(); loadRules(); loadSuggestions(); + loadInsights(); loadKnowledge(); loadThreads(); loadQueue(); + // Check for thread parameter in URL and open that thread + const urlParams = new URLSearchParams(window.location.search); + const threadParam = urlParams.get('thread'); + if (threadParam) { + // Switch to threads tab and open the thread + switchTab('threads'); + viewThread(threadParam); + } + // Event listeners for thread filters document.getElementById('stats-timeframe').addEventListener('change', loadThreads); document.getElementById('channel-filter').addEventListener('change', loadThreads); document.getElementById('threads-flagged-only').addEventListener('change', loadThreads); document.getElementById('threads-unreviewed-only').addEventListener('change', loadThreads); document.getElementById('threads-has-feedback').addEventListener('change', loadThreads); + document.getElementById('threads-multi-turn').addEventListener('change', loadThreads); diff --git a/server/public/admin-analytics.html b/server/public/admin-analytics.html index 2ec15fc90f..a583ec78d5 100644 --- a/server/public/admin-analytics.html +++ b/server/public/admin-analytics.html @@ -196,6 +196,36 @@ border-radius: 8px; margin-bottom: 20px; } + + .section-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; + } + + .btn-secondary { + background-color: var(--color-gray-100); + color: var(--color-text-heading); + border: 1px solid var(--color-border); + } + + .btn-secondary:hover { + background-color: var(--color-gray-200); + } + + .table-container { + max-height: 400px; + overflow-y: auto; + } + + .totals-row { + font-weight: 600; + background-color: var(--color-bg-subtle); + } + + .text-right { + text-align: right; + } @@ -273,6 +303,63 @@

Revenue by Product

+ + +
+

Membership by Company Type

+
+ +
+ + + + + + + + + + + +
Company TypeMembersEngagedRegisteredARR
+
+ + +
+

Membership by Revenue Tier

+ + + + + + + + + + + +
Revenue TierMembersEngagedRegisteredARR
+
+ + +
+

Full Breakdown (Company Type Γ— Revenue Tier)

+
+ + + + + + + + + + + + +
Company TypeRevenue TierMembersEngagedRegisteredARR
+
+
@@ -399,8 +486,120 @@

Revenue by Product

} } + // Escape HTML to prevent XSS + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + async function loadMembershipMetrics() { + try { + const response = await fetch('/api/admin/membership-metrics'); + if (!response.ok) { + throw new Error('Failed to load membership metrics'); + } + + const data = await response.json(); + + // Populate company type table + const companyTypeTable = document.getElementById('company-type-table').querySelector('tbody'); + if (data.by_company_type && data.by_company_type.length > 0) { + const rows = data.by_company_type.map(row => ` + + ${escapeHtml(row.label)} + ${row.members} + ${row.engaged} + ${row.registered} + ${formatCurrency(row.arr_dollars)} + + `).join(''); + + // Add totals row (excluding individuals which are separate) + const totals = data.by_company_type.reduce((acc, row) => ({ + members: acc.members + row.members, + engaged: acc.engaged + row.engaged, + registered: acc.registered + row.registered, + arr_dollars: acc.arr_dollars + row.arr_dollars, + }), { members: 0, engaged: 0, registered: 0, arr_dollars: 0 }); + + companyTypeTable.innerHTML = rows + ` + + Subtotal (Companies) + ${totals.members} + ${totals.engaged} + ${totals.registered} + ${formatCurrency(totals.arr_dollars)} + + + Individuals + ${data.individuals.members} + ${data.individuals.engaged} + ${data.individuals.registered} + ${formatCurrency(data.individuals.arr_dollars)} + + + Total + ${data.totals.members} + ${data.totals.engaged} + ${data.totals.registered} + ${formatCurrency(data.totals.arr_dollars)} + + `; + } else { + companyTypeTable.innerHTML = 'No data available'; + } + + // Populate revenue tier table + const revenueTierTable = document.getElementById('revenue-tier-table').querySelector('tbody'); + if (data.by_revenue_tier && data.by_revenue_tier.length > 0) { + revenueTierTable.innerHTML = data.by_revenue_tier.map(row => ` + + ${escapeHtml(row.label)} + ${row.members} + ${row.engaged} + ${row.registered} + ${formatCurrency(row.arr_dollars)} + + `).join(''); + } else { + revenueTierTable.innerHTML = 'No data available'; + } + + // Populate matrix table + const matrixTable = document.getElementById('matrix-table').querySelector('tbody'); + if (data.matrix && data.matrix.length > 0) { + matrixTable.innerHTML = data.matrix.map(row => ` + + ${escapeHtml(row.company_type_label)} + ${escapeHtml(row.revenue_tier_label)} + ${row.members} + ${row.engaged} + ${row.registered} + ${formatCurrency(row.arr_dollars)} + + `).join(''); + } else { + matrixTable.innerHTML = 'No data available'; + } + } catch (error) { + console.error('Error loading membership metrics:', error); + // Show error state in all membership tables + const errorHtml = 'Error loading data'; + const matrixErrorHtml = 'Error loading data'; + document.getElementById('company-type-table').querySelector('tbody').innerHTML = errorHtml; + document.getElementById('revenue-tier-table').querySelector('tbody').innerHTML = errorHtml; + document.getElementById('matrix-table').querySelector('tbody').innerHTML = matrixErrorHtml; + } + } + + function downloadMembershipCSV() { + window.location.href = '/api/admin/membership-metrics/csv'; + } + // Load analytics on page load loadAnalytics(); + loadMembershipMetrics(); diff --git a/server/public/admin-billing.html b/server/public/admin-billing.html index b8b03cb67c..e24833f321 100644 --- a/server/public/admin-billing.html +++ b/server/public/admin-billing.html @@ -45,6 +45,7 @@ } .stat-value.warning { color: var(--color-warning-600); } .stat-value.success { color: var(--color-success-600); } + .stat-value.error { color: var(--color-error-600); } .card { background: var(--color-bg-card); @@ -273,6 +274,70 @@ color: white; border-color: var(--color-brand); } + + /* Conflicts section */ + .conflicts-card { + border: var(--border-1) solid var(--color-error-200); + background: var(--color-error-50); + } + .conflicts-card .card-title { + color: var(--color-error-700); + } + .conflicts-table { + width: 100%; + border-collapse: collapse; + } + .conflicts-table th { + background: var(--color-gray-50); + padding: var(--space-3) var(--space-4); + text-align: left; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--color-text-heading); + border-bottom: var(--border-1) solid var(--color-border); + } + .conflicts-table td { + padding: var(--space-3) var(--space-4); + border-bottom: var(--border-1) solid var(--color-border); + font-size: var(--text-sm); + color: var(--color-text-heading); + background: white; + } + .conflicts-table tr:hover td { + background: var(--color-gray-50); + } + .conflict-choice { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + background: var(--color-gray-50); + border-radius: var(--radius-sm); + } + .conflict-choice-info { + flex: 1; + } + .conflict-choice-name { + font-weight: var(--font-medium); + color: var(--color-text-heading); + } + .conflict-choice-id { + font-size: var(--text-xs); + color: var(--color-text-secondary); + } + .conflict-info-box { + background: var(--color-primary-50); + border: var(--border-1) solid var(--color-primary-200); + border-radius: var(--radius-md); + padding: var(--space-3); + margin-bottom: var(--space-4); + font-size: var(--text-sm); + color: var(--color-primary-800); + } + .btn-sm { + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + } @@ -302,6 +367,32 @@

Stripe Customer Linking

Unlinked with Payments
-
+ + + + +
@@ -340,6 +431,7 @@

Stripe Customer Linking

diff --git a/server/public/admin-domain-health.html b/server/public/admin-domain-health.html index 6e89af7ddf..120ac6731b 100644 --- a/server/public/admin-domain-health.html +++ b/server/public/admin-domain-health.html @@ -131,6 +131,28 @@ color: var(--color-text-secondary); padding: var(--space-0.5) 0; } + .claimed-tag { + display: inline-block; + margin-left: var(--space-1); + padding: var(--space-0.5) var(--space-1); + background: var(--color-warning-100); + color: var(--color-warning-700); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-semibold); + } + .claimed-tag:hover { + background: var(--color-warning-50); + } + .personal-tag { + display: inline-block; + margin-left: var(--space-1); + padding: var(--space-0.5) var(--space-1); + background: var(--color-gray-100); + color: var(--color-text-muted); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + } .user-count { background: var(--color-gray-100); color: var(--color-text-secondary); @@ -252,6 +274,38 @@

Domain Health

+ + +
+
+
+
+ Backfill Slack User Links + loading... +
+
Link unmapped Slack users to existing organizations based on their email domain
+
+ +
+ + +
+ + +
+
+
+
+ Auto-Add Domain Users as Members + loading... +
+
Add users with verified domain emails directly as organization members (they have accounts but aren't members yet)
+
+ +
+ + +
@@ -262,7 +316,7 @@

Domain Health

Unlinked Corporate Domains 0 -

Corporate email domains not linked to any organization. Users may already be members of existing orgs - link the domain to consolidate.

+

Corporate email domains not linked to any organization. Link these domains to consolidate users.

@@ -278,6 +332,7 @@

Domain Health

Users in personal workspaces whose company already has an organization. Consider inviting them to join.

+
@@ -323,6 +378,20 @@

Domain Health

+ + +
+
+
+
+ Similar Organization Names + 0 +
+

Organizations with similar names that may be duplicates. Review and merge if they represent the same company.

+
+
+
+
@@ -381,12 +450,13 @@

Domain Health

renderUnverifiedOrgs(); renderDomainConflicts(); renderRelatedDomains(); + renderSimilarNames(); // Set up event delegation for merge buttons (avoiding inline onclick for XSS safety) document.querySelectorAll('.merge-orgs-btn').forEach(btn => { btn.addEventListener('click', () => { const orgIds = btn.dataset.orgIds.split(',').map(decodeURIComponent); - const domain = decodeURIComponent(btn.dataset.domain); + const domain = decodeURIComponent(btn.dataset.domain || btn.dataset.baseName || ''); showMergeModal(orgIds, domain); }); }); @@ -694,11 +764,112 @@

Domain Health

} } + async function linkAllMisalignedUsers(entries) { + const totalUsers = entries.reduce((sum, d) => sum + d.user_count, 0); + const totalOrgs = entries.length; + + if (!confirm(`Add ${totalUsers} user(s) to their ${totalOrgs} respective company org(s)?\n\nThis will add all listed users to their company organizations. They will keep access to their personal workspaces.`)) { + return; + } + + const linkAllBtn = document.getElementById('linkAllMisalignedBtn'); + const originalText = linkAllBtn.textContent; + linkAllBtn.disabled = true; + linkAllBtn.textContent = 'Linking...'; + + let successCount = 0; + let errorCount = 0; + const errors = []; + + // Group users by target org to minimize API calls + const usersByOrg = {}; + for (const entry of entries) { + if (!entry.target_org_id) continue; + if (!usersByOrg[entry.target_org_id]) { + usersByOrg[entry.target_org_id] = { + orgId: entry.target_org_id, + orgName: entry.target_org_name, + userIds: [] + }; + } + for (const user of entry.users) { + if (user.user_id) { + usersByOrg[entry.target_org_id].userIds.push(user.user_id); + } + } + } + + // Process each org + for (const orgData of Object.values(usersByOrg)) { + if (!isValidOrgId(orgData.orgId)) { + errors.push(`Invalid org ID: ${orgData.orgId}`); + errorCount += orgData.userIds.length; + continue; + } + + try { + const response = await fetch(`/api/admin/organizations/${encodeURIComponent(orgData.orgId)}/add-users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_ids: orgData.userIds }) + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminSidebar?.redirectToLogin(); + return; + } + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || errorData.error || 'Failed'); + } + + const result = await response.json(); + successCount += result.added_count || orgData.userIds.length; + if (Array.isArray(result.errors)) { + errors.push(...result.errors); + errorCount += result.errors.length; + } else if (result.errors) { + errors.push(String(result.errors)); + errorCount++; + } + } catch (error) { + errors.push(`${orgData.orgName}: ${error.message}`); + errorCount += orgData.userIds.length; + } + } + + // Show result + if (errorCount === 0) { + linkAllBtn.classList.remove('btn-primary'); + linkAllBtn.classList.add('btn-success'); + linkAllBtn.textContent = `Linked ${successCount} users!`; + } else { + linkAllBtn.classList.remove('btn-primary'); + linkAllBtn.classList.add('btn-error'); + linkAllBtn.textContent = `${successCount} linked, ${errorCount} failed`; + linkAllBtn.title = errors.join('\n'); + } + + // Reload after brief delay + setTimeout(() => { + loadDomainHealth(); + }, 1500); + } + function renderSummary() { - const { summary } = healthData; - const totalIssues = summary.issues.orphan_domains + summary.issues.misaligned_users + - summary.issues.unverified_orgs + summary.issues.domain_conflicts + - (summary.issues.related_domains || 0); + const { summary, orphan_domains } = healthData; + // Count orphan domains that have non-personal orgs (filtered version) + const filteredOrphanCount = (orphan_domains || []).filter(d => { + const nonPersonalOrgs = (d.existing_orgs || []).filter(o => !o.is_personal); + return nonPersonalOrgs.length > 0; + }).length; + + const totalIssues = filteredOrphanCount + + (summary.issues.misaligned_users || 0) + + (summary.issues.unverified_orgs || 0) + + (summary.issues.domain_conflicts || 0) + + (summary.issues.related_domains || 0) + + (summary.issues.similar_names || 0); const grid = document.getElementById('summaryGrid'); grid.innerHTML = ` @@ -730,14 +901,21 @@

Domain Health

const badge = document.getElementById('orphanBadge'); const content = document.getElementById('orphanDomainsContent'); - badge.textContent = orphan_domains.length; - badge.className = orphan_domains.length === 0 ? 'section-badge success' : 'section-badge'; + // Filter to only show domains where users are in NON-personal orgs + // (i.e., exclude cases where users are only in personal workspaces) + const domainsWithCorpOrgs = (orphan_domains || []).filter(d => { + const nonPersonalOrgs = (d.existing_orgs || []).filter(o => !o.is_personal); + return nonPersonalOrgs.length > 0; + }); + + badge.textContent = domainsWithCorpOrgs.length; + badge.className = domainsWithCorpOrgs.length === 0 ? 'section-badge success' : 'section-badge'; - if (orphan_domains.length === 0) { + if (domainsWithCorpOrgs.length === 0) { content.innerHTML = `
-

No unlinked domains found. All corporate domains are properly linked to organizations.

+

No unlinked corporate domains found. All corporate domains are properly linked to organizations.

`; return; @@ -754,8 +932,8 @@

Domain Health

- ${orphan_domains.map(d => { - // Filter to non-personal orgs first, then personal + ${domainsWithCorpOrgs.map(d => { + // Filter to non-personal orgs first // Sort by value: Stripe customers first, then active subscriptions, then by user count const scoreOrg = (o) => { let score = 0; @@ -767,8 +945,6 @@

Domain Health

const nonPersonalOrgs = (d.existing_orgs || []) .filter(o => !o.is_personal) .sort((a, b) => scoreOrg(b) - scoreOrg(a)); - const personalOrgs = (d.existing_orgs || []).filter(o => o.is_personal); - const hasExistingOrgs = nonPersonalOrgs.length > 0; const hasMultipleOrgs = nonPersonalOrgs.length > 1; return ` @@ -776,42 +952,30 @@

Domain Health

${escapeHtml(d.domain)} ${d.user_count} - ${hasExistingOrgs ? ` - - ${personalOrgs.length > 0 ? `
+ ${personalOrgs.length} personal workspace${personalOrgs.length > 1 ? 's' : ''}
` : ''} - ` : ` - - ${personalOrgs.length > 0 ? `${personalOrgs.length} personal workspace${personalOrgs.length > 1 ? 's' : ''} only` : 'No existing orgs'} - - `} +
- ${hasExistingOrgs ? ` - ${nonPersonalOrgs.length === 1 ? ` - - ` : ` - - `} - ${hasMultipleOrgs ? ` - - ` : ''} + ${nonPersonalOrgs.length === 1 ? ` + ` : ` - + `} - + ${hasMultipleOrgs ? ` + + ` : ''}
@@ -825,11 +989,25 @@

Domain Health

const { misaligned_users } = healthData; const badge = document.getElementById('misalignedBadge'); const content = document.getElementById('misalignedUsersContent'); + const linkAllBtn = document.getElementById('linkAllMisalignedBtn'); const totalUsers = misaligned_users.reduce((sum, d) => sum + d.user_count, 0); badge.textContent = totalUsers; badge.className = totalUsers === 0 ? 'section-badge success' : 'section-badge'; + // Filter to only entries that have a target org (can be linked) + const linkableEntries = misaligned_users.filter(d => d.target_org_id); + const linkableUsers = linkableEntries.reduce((sum, d) => sum + d.user_count, 0); + + // Show/hide Link All button + if (linkableUsers > 0) { + linkAllBtn.style.display = 'block'; + linkAllBtn.textContent = `Link All (${linkableUsers} users)`; + linkAllBtn.onclick = () => linkAllMisalignedUsers(linkableEntries); + } else { + linkAllBtn.style.display = 'none'; + } + if (misaligned_users.length === 0) { content.innerHTML = `
@@ -934,8 +1112,18 @@

Domain Health

${unverified_orgs.map(o => { - // Find the first corporate domain (not a personal email domain) - const corporateDomains = (o.user_domains || []).filter(d => d && !personalDomains.includes(d.toLowerCase())); + // Build a map of claimed domains to their claiming org info + const claimedDomainsMap = new Map(); + (o.claimed_by_others || []).forEach(claim => { + if (claim && claim.domain) { + claimedDomainsMap.set(claim.domain.toLowerCase(), claim); + } + }); + + // Find the first corporate domain (not a personal email domain AND not claimed by another org) + const corporateDomains = (o.user_domains || []).filter(d => + d && !personalDomains.includes(d.toLowerCase()) && !claimedDomainsMap.has(d.toLowerCase()) + ); const suggestedDomain = corporateDomains[0] || null; return ` @@ -947,7 +1135,16 @@

Domain Health

${o.user_count} @@ -1093,6 +1290,64 @@

Domain Health

`; } + function renderSimilarNames() { + const similarNames = healthData.similar_names || []; + const badge = document.getElementById('similarNamesBadge'); + const content = document.getElementById('similarNamesContent'); + + badge.textContent = similarNames.length; + badge.className = similarNames.length === 0 ? 'section-badge success' : 'section-badge'; + + if (similarNames.length === 0) { + content.innerHTML = ` +
+
+

No organizations found with similar names.

+
+ `; + return; + } + + content.innerHTML = ` + + + + + + + + + + ${similarNames.map(s => ` + + + + + + `).join('')} + +
Name PatternSimilar OrganizationsActions
+ ${escapeHtml(s.base_name)} +
${s.org_count} organizations +
+
    + ${s.organizations.map(o => ` +
  • + ${escapeHtml(o.name)} + + ${escapeHtml(o.subscription_status || 'prospect')} + +
  • + `).join('')} +
+
+ +
+ `; + } + // Merge modal functionality let mergeModalElement = null; @@ -1431,8 +1686,276 @@

All } } + // Load backfill status on page load + async function loadBackfillStatus() { + const countSpan = document.getElementById('backfillCount'); + const btn = document.getElementById('backfillSlackBtn'); + const previewDiv = document.getElementById('backfillPreview'); + + try { + const response = await fetch('/api/admin/slack/backfill-status'); + if (!response.ok) { + throw new Error('Failed to load status'); + } + const status = await response.json(); + + if (status.totalUsers === 0) { + countSpan.textContent = '0 users'; + countSpan.style.background = 'var(--color-success-100)'; + countSpan.style.color = 'var(--color-success-700)'; + btn.disabled = true; + btn.textContent = 'No users to link'; + } else { + countSpan.textContent = `${status.totalUsers} users`; + countSpan.style.background = 'var(--color-warning-100)'; + countSpan.style.color = 'var(--color-warning-700)'; + btn.disabled = false; + btn.textContent = `Link ${status.totalUsers} Users`; + + // Show preview of what will be linked + if (status.byOrganization.length > 0) { + previewDiv.innerHTML = ` +
+
Users to be linked:
+
    + ${status.byOrganization.slice(0, 5).map(o => `
  • ${escapeHtml(o.organizationName)} (${escapeHtml(o.domain)}): ${o.userCount} users
  • `).join('')} + ${status.byOrganization.length > 5 ? `
  • ...and ${status.byOrganization.length - 5} more organizations
  • ` : ''} +
+
+ `; + previewDiv.style.display = 'block'; + } + } + } catch (error) { + countSpan.textContent = 'error'; + countSpan.style.background = 'var(--color-error-100)'; + countSpan.style.color = 'var(--color-error-700)'; + btn.disabled = true; + } + } + + // Backfill Slack user links + async function runSlackBackfill() { + const btn = document.getElementById('backfillSlackBtn'); + const resultDiv = document.getElementById('backfillResult'); + const previewDiv = document.getElementById('backfillPreview'); + + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Running...'; + resultDiv.style.display = 'none'; + previewDiv.style.display = 'none'; + + try { + const response = await fetch('/api/admin/slack/backfill-pending-orgs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminSidebar?.redirectToLogin(); + return; + } + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Backfill failed'); + } + + const result = await response.json(); + + // Show success result + btn.classList.remove('btn-secondary'); + btn.classList.add('btn-success'); + btn.textContent = 'Complete'; + + if (result.usersLinked > 0) { + resultDiv.innerHTML = ` +
+
+ Linked ${result.usersLinked} Slack users to ${result.details.length} organizations +
+
    + ${result.details.slice(0, 5).map(d => `
  • ${escapeHtml(d.organizationName)} (${d.domain}): ${d.usersLinked} users
  • `).join('')} + ${result.details.length > 5 ? `
  • ...and ${result.details.length - 5} more
  • ` : ''} +
+
+ `; + } else { + resultDiv.innerHTML = ` +
+
No unmapped Slack users found to link. All users are already associated with their organizations.
+
+ `; + } + resultDiv.style.display = 'block'; + + // Refresh status after delay + setTimeout(() => { + loadBackfillStatus(); + btn.classList.remove('btn-success'); + btn.classList.add('btn-secondary'); + }, 3000); + + } catch (error) { + btn.classList.remove('btn-secondary'); + btn.classList.add('btn-error'); + btn.textContent = 'Failed'; + + resultDiv.innerHTML = ` +
+
Error: ${escapeHtml(error.message)}
+
+ `; + resultDiv.style.display = 'block'; + + setTimeout(() => { + btn.disabled = false; + btn.classList.remove('btn-error'); + btn.classList.add('btn-secondary'); + btn.textContent = originalText; + }, 3000); + } + } + + // Load member backfill status + async function loadMemberBackfillStatus() { + const countSpan = document.getElementById('memberBackfillCount'); + const btn = document.getElementById('memberBackfillBtn'); + const previewDiv = document.getElementById('memberBackfillPreview'); + + try { + const response = await fetch('/api/admin/domain-users/backfill-status'); + if (!response.ok) { + throw new Error('Failed to load status'); + } + const status = await response.json(); + + if (status.total_users === 0) { + countSpan.textContent = '0 users'; + countSpan.style.background = 'var(--color-success-100)'; + countSpan.style.color = 'var(--color-success-700)'; + btn.disabled = true; + btn.textContent = 'No users to add'; + } else { + countSpan.textContent = `${status.total_users} users`; + countSpan.style.background = 'var(--color-warning-100)'; + countSpan.style.color = 'var(--color-warning-700)'; + btn.disabled = false; + btn.textContent = `Add ${status.total_users} Members`; + + // Show preview of what will be added + if (status.by_organization.length > 0) { + previewDiv.innerHTML = ` +
+
Users to be added as members:
+
    + ${status.by_organization.slice(0, 5).map(o => `
  • ${escapeHtml(o.org_name)} (${escapeHtml(o.domain)}): ${o.users.length} users
  • `).join('')} + ${status.by_organization.length > 5 ? `
  • ...and ${status.by_organization.length - 5} more organizations
  • ` : ''} +
+
+ `; + previewDiv.style.display = 'block'; + } + } + } catch (error) { + countSpan.textContent = 'error'; + countSpan.style.background = 'var(--color-error-100)'; + countSpan.style.color = 'var(--color-error-700)'; + btn.disabled = true; + } + } + + // Run member backfill + async function runMemberBackfill() { + const btn = document.getElementById('memberBackfillBtn'); + const resultDiv = document.getElementById('memberBackfillResult'); + const previewDiv = document.getElementById('memberBackfillPreview'); + + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Adding members...'; + resultDiv.style.display = 'none'; + previewDiv.style.display = 'none'; + + try { + const response = await fetch('/api/admin/domain-users/backfill-members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminSidebar?.redirectToLogin(); + return; + } + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Backfill failed'); + } + + const result = await response.json(); + + // Show success result + btn.classList.remove('btn-primary'); + btn.classList.add('btn-success'); + btn.textContent = 'Complete'; + + if (result.total_added > 0) { + resultDiv.innerHTML = ` +
+
+ Added ${result.total_added} members to ${result.orgs_processed} organizations +
+ ${result.total_skipped > 0 ? `
${result.total_skipped} already members (skipped)
` : ''} + ${result.total_errors > 0 ? `
${result.total_errors} errors
` : ''} +
    + ${result.details.slice(0, 5).map(d => `
  • ${escapeHtml(d.org_name)} (${d.domain}): ${d.added} added${d.skipped > 0 ? `, ${d.skipped} skipped` : ''}
  • `).join('')} + ${result.details.length > 5 ? `
  • ...and ${result.details.length - 5} more
  • ` : ''} +
+
+ `; + } else { + resultDiv.innerHTML = ` +
+
No users to add. All verified domain users are already members of their organizations.
+
+ `; + } + resultDiv.style.display = 'block'; + + // Refresh status after delay + setTimeout(() => { + loadMemberBackfillStatus(); + btn.classList.remove('btn-success'); + btn.classList.add('btn-primary'); + }, 3000); + + } catch (error) { + btn.classList.remove('btn-primary'); + btn.classList.add('btn-error'); + btn.textContent = 'Failed'; + + resultDiv.innerHTML = ` +
+
Error: ${escapeHtml(error.message)}
+
+ `; + resultDiv.style.display = 'block'; + + setTimeout(() => { + btn.disabled = false; + btn.classList.remove('btn-error'); + btn.classList.add('btn-primary'); + btn.textContent = originalText; + }, 3000); + } + } + // Initialize + document.getElementById('backfillSlackBtn').addEventListener('click', runSlackBackfill); + document.getElementById('memberBackfillBtn').addEventListener('click', runMemberBackfill); loadDomainHealth(); + loadBackfillStatus(); + loadMemberBackfillStatus(); diff --git a/server/public/admin-insight-goals.html b/server/public/admin-insight-goals.html deleted file mode 100644 index ec4f4dcba0..0000000000 --- a/server/public/admin-insight-goals.html +++ /dev/null @@ -1,580 +0,0 @@ - - - - - - - Admin - Insight Goals - AgenticAdvertising.org - - - - - - -
- -
- - -
-
Loading insight goals...
- - - - - - - - - - - - - - - - -
-
- - - - - - - - - diff --git a/server/public/admin-meetings.html b/server/public/admin-meetings.html new file mode 100644 index 0000000000..f6aa4e2009 --- /dev/null +++ b/server/public/admin-meetings.html @@ -0,0 +1,793 @@ + + + + + + + Admin - Meetings - AgenticAdvertising.org + + + + + + +
+ +
+ Loading... +
+ + + + + + + + + + + + diff --git a/server/public/admin-org-detail.html b/server/public/admin-org-detail.html index abb1e6a177..0676d408ae 100644 --- a/server/public/admin-org-detail.html +++ b/server/public/admin-org-detail.html @@ -1356,6 +1356,25 @@

+
+ Review Invoice Draft +

+ Please review all details before sending. The invoice will be emailed to the contact. +

+
+ +
+ +
+ + +

+