From 1f84ac49ed2d4371ac4f14e46d05f379299c7ab2 Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Wed, 7 Jan 2026 14:40:46 -0500 Subject: [PATCH 01/77] feat: add additionalProperties: true to all schemas (v2.5.2) Allow additional properties in all JSON schemas for forward compatibility. This enables clients to ignore unknown fields when parsing responses, making it easier to evolve the API without breaking existing integrations. Generated dist/schemas/2.5.2/ with relaxed validation. --- dist/schemas/2.5.2/adagents.json | 494 ++++ .../media-buy/build-creative-request.json | 1349 +++++++++++ .../media-buy/build-creative-response.json | 1383 +++++++++++ .../media-buy/create-media-buy-request.json | 2015 +++++++++++++++++ .../media-buy/create-media-buy-response.json | 356 +++ .../get-media-buy-delivery-request.json | 92 + .../get-media-buy-delivery-response.json | 700 ++++++ .../media-buy/get-products-request.json | 657 ++++++ .../media-buy/get-products-response.json | 1313 +++++++++++ .../list-authorized-properties-request.json | 36 + .../list-authorized-properties-response.json | 128 ++ .../list-creative-formats-request.json | 137 ++ .../list-creative-formats-response.json | 646 ++++++ .../media-buy/list-creatives-request.json | 294 +++ .../media-buy/list-creatives-response.json | 1654 ++++++++++++++ .../bundled/media-buy/package-request.json | 1423 ++++++++++++ .../provide-performance-feedback-request.json | 116 + ...provide-performance-feedback-response.json | 117 + .../media-buy/sync-creatives-request.json | 1475 ++++++++++++ .../media-buy/sync-creatives-response.json | 208 ++ .../media-buy/update-media-buy-request.json | 1525 +++++++++++++ .../media-buy/update-media-buy-response.json | 358 +++ .../signals/activate-signal-request.json | 90 + .../signals/activate-signal-response.json | 295 +++ .../bundled/signals/get-signals-request.json | 154 ++ .../bundled/signals/get-signals-response.json | 327 +++ dist/schemas/2.5.2/core/activation-key.json | 50 + .../2.5.2/core/assets/audio-asset.json | 32 + dist/schemas/2.5.2/core/assets/css-asset.json | 21 + .../2.5.2/core/assets/daast-asset.json | 87 + .../schemas/2.5.2/core/assets/html-asset.json | 21 + .../2.5.2/core/assets/image-asset.json | 38 + .../2.5.2/core/assets/javascript-asset.json | 21 + .../2.5.2/core/assets/markdown-asset.json | 31 + .../schemas/2.5.2/core/assets/text-asset.json | 21 + dist/schemas/2.5.2/core/assets/url-asset.json | 26 + .../schemas/2.5.2/core/assets/vast-asset.json | 87 + .../2.5.2/core/assets/video-asset.json | 44 + .../2.5.2/core/assets/webhook-asset.json | 71 + .../2.5.2/core/async-response-data.json | 88 + .../2.5.2/core/brand-manifest-ref.json | 33 + dist/schemas/2.5.2/core/brand-manifest.json | 409 ++++ dist/schemas/2.5.2/core/context.json | 8 + dist/schemas/2.5.2/core/creative-asset.json | 125 + .../2.5.2/core/creative-assignment.json | 31 + dist/schemas/2.5.2/core/creative-filters.json | 111 + .../schemas/2.5.2/core/creative-manifest.json | 72 + dist/schemas/2.5.2/core/creative-policy.json | 27 + dist/schemas/2.5.2/core/delivery-metrics.json | 171 ++ dist/schemas/2.5.2/core/deployment.json | 93 + dist/schemas/2.5.2/core/destination.json | 53 + dist/schemas/2.5.2/core/error.json | 40 + dist/schemas/2.5.2/core/ext.json | 8 + dist/schemas/2.5.2/core/format-id.json | 47 + dist/schemas/2.5.2/core/format.json | 324 +++ dist/schemas/2.5.2/core/frequency-cap.json | 18 + .../2.5.2/core/mcp-webhook-payload.json | 152 ++ dist/schemas/2.5.2/core/measurement.json | 48 + dist/schemas/2.5.2/core/media-buy.json | 62 + dist/schemas/2.5.2/core/package.json | 72 + .../2.5.2/core/performance-feedback.json | 90 + dist/schemas/2.5.2/core/placement.json | 34 + dist/schemas/2.5.2/core/pricing-option.json | 35 + dist/schemas/2.5.2/core/product-filters.json | 102 + dist/schemas/2.5.2/core/product.json | 153 ++ .../2.5.2/core/promoted-offerings.json | 115 + .../schemas/2.5.2/core/promoted-products.json | 67 + dist/schemas/2.5.2/core/property-id.json | 14 + dist/schemas/2.5.2/core/property-tag.json | 16 + dist/schemas/2.5.2/core/property.json | 62 + .../schemas/2.5.2/core/protocol-envelope.json | 146 ++ .../core/publisher-property-selector.json | 92 + .../2.5.2/core/push-notification-config.json | 48 + .../2.5.2/core/reporting-capabilities.json | 71 + dist/schemas/2.5.2/core/response.json | 24 + dist/schemas/2.5.2/core/signal-filters.json | 35 + dist/schemas/2.5.2/core/start-timing.json | 18 + dist/schemas/2.5.2/core/sub-asset.json | 79 + dist/schemas/2.5.2/core/targeting.json | 50 + .../schemas/2.5.2/core/tasks-get-request.json | 43 + .../2.5.2/core/tasks-get-response.json | 166 ++ .../2.5.2/core/tasks-list-request.json | 192 ++ .../2.5.2/core/tasks-list-response.json | 183 ++ .../2.5.2/creative/asset-types/index.json | 106 + .../list-creative-formats-request.json | 73 + .../list-creative-formats-response.json | 61 + .../creative/preview-creative-request.json | 164 ++ .../creative/preview-creative-response.json | 245 ++ .../2.5.2/creative/preview-render.json | 225 ++ dist/schemas/2.5.2/enums/adcp-domain.json | 11 + .../2.5.2/enums/asset-content-type.json | 22 + dist/schemas/2.5.2/enums/auth-scheme.json | 11 + .../schemas/2.5.2/enums/available-metric.json | 18 + dist/schemas/2.5.2/enums/channels.json | 18 + .../2.5.2/enums/co-branding-requirement.json | 12 + dist/schemas/2.5.2/enums/creative-action.json | 14 + .../enums/creative-agent-capability.json | 13 + .../2.5.2/enums/creative-sort-field.json | 15 + dist/schemas/2.5.2/enums/creative-status.json | 19 + .../2.5.2/enums/daast-tracking-event.json | 20 + dist/schemas/2.5.2/enums/daast-version.json | 11 + dist/schemas/2.5.2/enums/delivery-type.json | 15 + dist/schemas/2.5.2/enums/dimension-unit.json | 13 + dist/schemas/2.5.2/enums/feed-format.json | 12 + dist/schemas/2.5.2/enums/feedback-source.json | 13 + dist/schemas/2.5.2/enums/format-category.json | 16 + .../2.5.2/enums/format-id-parameter.json | 11 + .../2.5.2/enums/frequency-cap-scope.json | 13 + .../2.5.2/enums/history-entry-type.json | 11 + dist/schemas/2.5.2/enums/http-method.json | 11 + .../schemas/2.5.2/enums/identifier-types.json | 34 + .../2.5.2/enums/javascript-module-type.json | 12 + .../2.5.2/enums/landing-page-requirement.json | 12 + dist/schemas/2.5.2/enums/markdown-flavor.json | 11 + .../schemas/2.5.2/enums/media-buy-status.json | 19 + dist/schemas/2.5.2/enums/metric-type.json | 17 + .../2.5.2/enums/notification-type.json | 13 + dist/schemas/2.5.2/enums/pacing.json | 17 + .../2.5.2/enums/preview-output-format.json | 11 + dist/schemas/2.5.2/enums/pricing-model.json | 25 + dist/schemas/2.5.2/enums/property-type.json | 16 + .../enums/publisher-identifier-types.json | 19 + .../2.5.2/enums/reporting-frequency.json | 12 + .../2.5.2/enums/signal-catalog-type.json | 12 + dist/schemas/2.5.2/enums/sort-direction.json | 11 + .../2.5.2/enums/standard-format-ids.json | 103 + dist/schemas/2.5.2/enums/task-status.json | 29 + dist/schemas/2.5.2/enums/task-type.json | 27 + .../schemas/2.5.2/enums/update-frequency.json | 13 + dist/schemas/2.5.2/enums/url-asset-type.json | 12 + dist/schemas/2.5.2/enums/validation-mode.json | 11 + .../2.5.2/enums/vast-tracking-event.json | 25 + dist/schemas/2.5.2/enums/vast-version.json | 14 + .../2.5.2/enums/webhook-response-type.json | 13 + .../2.5.2/enums/webhook-security-method.json | 12 + dist/schemas/2.5.2/index.json | 604 +++++ .../media-buy/build-creative-request.json | 31 + .../media-buy/build-creative-response.json | 65 + ...dia-buy-async-response-input-required.json | 31 + ...te-media-buy-async-response-submitted.json | 16 + ...eate-media-buy-async-response-working.json | 36 + .../media-buy/create-media-buy-request.json | 126 ++ .../media-buy/create-media-buy-response.json | 97 + .../get-media-buy-delivery-request.json | 54 + .../get-media-buy-delivery-response.json | 285 +++ ...roducts-async-response-input-required.json | 38 + ...get-products-async-response-submitted.json | 21 + .../get-products-async-response-working.json | 34 + .../2.5.2/media-buy/get-products-request.json | 28 + .../media-buy/get-products-response.json | 33 + .../list-authorized-properties-request.json | 26 + .../list-authorized-properties-response.json | 70 + .../list-creative-formats-request.json | 58 + .../list-creative-formats-response.json | 61 + .../media-buy/list-creatives-request.json | 137 ++ .../media-buy/list-creatives-response.json | 437 ++++ .../2.5.2/media-buy/package-request.json | 70 + .../provide-performance-feedback-request.json | 88 + ...provide-performance-feedback-response.json | 66 + ...eatives-async-response-input-required.json | 25 + ...nc-creatives-async-response-submitted.json | 16 + ...sync-creatives-async-response-working.json | 46 + .../media-buy/sync-creatives-request.json | 178 ++ .../media-buy/sync-creatives-response.json | 149 ++ ...dia-buy-async-response-input-required.json | 24 + ...te-media-buy-async-response-submitted.json | 16 + ...date-media-buy-async-response-working.json | 36 + .../media-buy/update-media-buy-request.json | 124 + .../media-buy/update-media-buy-response.json | 99 + .../2.5.2/pricing-options/cpc-option.json | 52 + .../2.5.2/pricing-options/cpcv-option.json | 52 + .../pricing-options/cpm-auction-option.json | 81 + .../pricing-options/cpm-fixed-option.json | 52 + .../2.5.2/pricing-options/cpp-option.json | 73 + .../2.5.2/pricing-options/cpv-option.json | 88 + .../pricing-options/flat-rate-option.json | 93 + .../pricing-options/vcpm-auction-option.json | 81 + .../pricing-options/vcpm-fixed-option.json | 52 + .../2.5.2/protocols/adcp-extension.json | 33 + .../signals/activate-signal-request.json | 32 + .../signals/activate-signal-response.json | 68 + .../2.5.2/signals/get-signals-request.json | 59 + .../2.5.2/signals/get-signals-response.json | 100 + 183 files changed, 27533 insertions(+) create mode 100644 dist/schemas/2.5.2/adagents.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/build-creative-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/build-creative-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/create-media-buy-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/create-media-buy-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/get-media-buy-delivery-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/get-media-buy-delivery-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/get-products-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/get-products-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/list-authorized-properties-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/list-authorized-properties-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/list-creative-formats-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/list-creative-formats-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/list-creatives-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/list-creatives-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/package-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/provide-performance-feedback-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/provide-performance-feedback-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/sync-creatives-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/sync-creatives-response.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/update-media-buy-request.json create mode 100644 dist/schemas/2.5.2/bundled/media-buy/update-media-buy-response.json create mode 100644 dist/schemas/2.5.2/bundled/signals/activate-signal-request.json create mode 100644 dist/schemas/2.5.2/bundled/signals/activate-signal-response.json create mode 100644 dist/schemas/2.5.2/bundled/signals/get-signals-request.json create mode 100644 dist/schemas/2.5.2/bundled/signals/get-signals-response.json create mode 100644 dist/schemas/2.5.2/core/activation-key.json create mode 100644 dist/schemas/2.5.2/core/assets/audio-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/css-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/daast-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/html-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/image-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/javascript-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/markdown-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/text-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/url-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/vast-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/video-asset.json create mode 100644 dist/schemas/2.5.2/core/assets/webhook-asset.json create mode 100644 dist/schemas/2.5.2/core/async-response-data.json create mode 100644 dist/schemas/2.5.2/core/brand-manifest-ref.json create mode 100644 dist/schemas/2.5.2/core/brand-manifest.json create mode 100644 dist/schemas/2.5.2/core/context.json create mode 100644 dist/schemas/2.5.2/core/creative-asset.json create mode 100644 dist/schemas/2.5.2/core/creative-assignment.json create mode 100644 dist/schemas/2.5.2/core/creative-filters.json create mode 100644 dist/schemas/2.5.2/core/creative-manifest.json create mode 100644 dist/schemas/2.5.2/core/creative-policy.json create mode 100644 dist/schemas/2.5.2/core/delivery-metrics.json create mode 100644 dist/schemas/2.5.2/core/deployment.json create mode 100644 dist/schemas/2.5.2/core/destination.json create mode 100644 dist/schemas/2.5.2/core/error.json create mode 100644 dist/schemas/2.5.2/core/ext.json create mode 100644 dist/schemas/2.5.2/core/format-id.json create mode 100644 dist/schemas/2.5.2/core/format.json create mode 100644 dist/schemas/2.5.2/core/frequency-cap.json create mode 100644 dist/schemas/2.5.2/core/mcp-webhook-payload.json create mode 100644 dist/schemas/2.5.2/core/measurement.json create mode 100644 dist/schemas/2.5.2/core/media-buy.json create mode 100644 dist/schemas/2.5.2/core/package.json create mode 100644 dist/schemas/2.5.2/core/performance-feedback.json create mode 100644 dist/schemas/2.5.2/core/placement.json create mode 100644 dist/schemas/2.5.2/core/pricing-option.json create mode 100644 dist/schemas/2.5.2/core/product-filters.json create mode 100644 dist/schemas/2.5.2/core/product.json create mode 100644 dist/schemas/2.5.2/core/promoted-offerings.json create mode 100644 dist/schemas/2.5.2/core/promoted-products.json create mode 100644 dist/schemas/2.5.2/core/property-id.json create mode 100644 dist/schemas/2.5.2/core/property-tag.json create mode 100644 dist/schemas/2.5.2/core/property.json create mode 100644 dist/schemas/2.5.2/core/protocol-envelope.json create mode 100644 dist/schemas/2.5.2/core/publisher-property-selector.json create mode 100644 dist/schemas/2.5.2/core/push-notification-config.json create mode 100644 dist/schemas/2.5.2/core/reporting-capabilities.json create mode 100644 dist/schemas/2.5.2/core/response.json create mode 100644 dist/schemas/2.5.2/core/signal-filters.json create mode 100644 dist/schemas/2.5.2/core/start-timing.json create mode 100644 dist/schemas/2.5.2/core/sub-asset.json create mode 100644 dist/schemas/2.5.2/core/targeting.json create mode 100644 dist/schemas/2.5.2/core/tasks-get-request.json create mode 100644 dist/schemas/2.5.2/core/tasks-get-response.json create mode 100644 dist/schemas/2.5.2/core/tasks-list-request.json create mode 100644 dist/schemas/2.5.2/core/tasks-list-response.json create mode 100644 dist/schemas/2.5.2/creative/asset-types/index.json create mode 100644 dist/schemas/2.5.2/creative/list-creative-formats-request.json create mode 100644 dist/schemas/2.5.2/creative/list-creative-formats-response.json create mode 100644 dist/schemas/2.5.2/creative/preview-creative-request.json create mode 100644 dist/schemas/2.5.2/creative/preview-creative-response.json create mode 100644 dist/schemas/2.5.2/creative/preview-render.json create mode 100644 dist/schemas/2.5.2/enums/adcp-domain.json create mode 100644 dist/schemas/2.5.2/enums/asset-content-type.json create mode 100644 dist/schemas/2.5.2/enums/auth-scheme.json create mode 100644 dist/schemas/2.5.2/enums/available-metric.json create mode 100644 dist/schemas/2.5.2/enums/channels.json create mode 100644 dist/schemas/2.5.2/enums/co-branding-requirement.json create mode 100644 dist/schemas/2.5.2/enums/creative-action.json create mode 100644 dist/schemas/2.5.2/enums/creative-agent-capability.json create mode 100644 dist/schemas/2.5.2/enums/creative-sort-field.json create mode 100644 dist/schemas/2.5.2/enums/creative-status.json create mode 100644 dist/schemas/2.5.2/enums/daast-tracking-event.json create mode 100644 dist/schemas/2.5.2/enums/daast-version.json create mode 100644 dist/schemas/2.5.2/enums/delivery-type.json create mode 100644 dist/schemas/2.5.2/enums/dimension-unit.json create mode 100644 dist/schemas/2.5.2/enums/feed-format.json create mode 100644 dist/schemas/2.5.2/enums/feedback-source.json create mode 100644 dist/schemas/2.5.2/enums/format-category.json create mode 100644 dist/schemas/2.5.2/enums/format-id-parameter.json create mode 100644 dist/schemas/2.5.2/enums/frequency-cap-scope.json create mode 100644 dist/schemas/2.5.2/enums/history-entry-type.json create mode 100644 dist/schemas/2.5.2/enums/http-method.json create mode 100644 dist/schemas/2.5.2/enums/identifier-types.json create mode 100644 dist/schemas/2.5.2/enums/javascript-module-type.json create mode 100644 dist/schemas/2.5.2/enums/landing-page-requirement.json create mode 100644 dist/schemas/2.5.2/enums/markdown-flavor.json create mode 100644 dist/schemas/2.5.2/enums/media-buy-status.json create mode 100644 dist/schemas/2.5.2/enums/metric-type.json create mode 100644 dist/schemas/2.5.2/enums/notification-type.json create mode 100644 dist/schemas/2.5.2/enums/pacing.json create mode 100644 dist/schemas/2.5.2/enums/preview-output-format.json create mode 100644 dist/schemas/2.5.2/enums/pricing-model.json create mode 100644 dist/schemas/2.5.2/enums/property-type.json create mode 100644 dist/schemas/2.5.2/enums/publisher-identifier-types.json create mode 100644 dist/schemas/2.5.2/enums/reporting-frequency.json create mode 100644 dist/schemas/2.5.2/enums/signal-catalog-type.json create mode 100644 dist/schemas/2.5.2/enums/sort-direction.json create mode 100644 dist/schemas/2.5.2/enums/standard-format-ids.json create mode 100644 dist/schemas/2.5.2/enums/task-status.json create mode 100644 dist/schemas/2.5.2/enums/task-type.json create mode 100644 dist/schemas/2.5.2/enums/update-frequency.json create mode 100644 dist/schemas/2.5.2/enums/url-asset-type.json create mode 100644 dist/schemas/2.5.2/enums/validation-mode.json create mode 100644 dist/schemas/2.5.2/enums/vast-tracking-event.json create mode 100644 dist/schemas/2.5.2/enums/vast-version.json create mode 100644 dist/schemas/2.5.2/enums/webhook-response-type.json create mode 100644 dist/schemas/2.5.2/enums/webhook-security-method.json create mode 100644 dist/schemas/2.5.2/index.json create mode 100644 dist/schemas/2.5.2/media-buy/build-creative-request.json create mode 100644 dist/schemas/2.5.2/media-buy/build-creative-response.json create mode 100644 dist/schemas/2.5.2/media-buy/create-media-buy-async-response-input-required.json create mode 100644 dist/schemas/2.5.2/media-buy/create-media-buy-async-response-submitted.json create mode 100644 dist/schemas/2.5.2/media-buy/create-media-buy-async-response-working.json create mode 100644 dist/schemas/2.5.2/media-buy/create-media-buy-request.json create mode 100644 dist/schemas/2.5.2/media-buy/create-media-buy-response.json create mode 100644 dist/schemas/2.5.2/media-buy/get-media-buy-delivery-request.json create mode 100644 dist/schemas/2.5.2/media-buy/get-media-buy-delivery-response.json create mode 100644 dist/schemas/2.5.2/media-buy/get-products-async-response-input-required.json create mode 100644 dist/schemas/2.5.2/media-buy/get-products-async-response-submitted.json create mode 100644 dist/schemas/2.5.2/media-buy/get-products-async-response-working.json create mode 100644 dist/schemas/2.5.2/media-buy/get-products-request.json create mode 100644 dist/schemas/2.5.2/media-buy/get-products-response.json create mode 100644 dist/schemas/2.5.2/media-buy/list-authorized-properties-request.json create mode 100644 dist/schemas/2.5.2/media-buy/list-authorized-properties-response.json create mode 100644 dist/schemas/2.5.2/media-buy/list-creative-formats-request.json create mode 100644 dist/schemas/2.5.2/media-buy/list-creative-formats-response.json create mode 100644 dist/schemas/2.5.2/media-buy/list-creatives-request.json create mode 100644 dist/schemas/2.5.2/media-buy/list-creatives-response.json create mode 100644 dist/schemas/2.5.2/media-buy/package-request.json create mode 100644 dist/schemas/2.5.2/media-buy/provide-performance-feedback-request.json create mode 100644 dist/schemas/2.5.2/media-buy/provide-performance-feedback-response.json create mode 100644 dist/schemas/2.5.2/media-buy/sync-creatives-async-response-input-required.json create mode 100644 dist/schemas/2.5.2/media-buy/sync-creatives-async-response-submitted.json create mode 100644 dist/schemas/2.5.2/media-buy/sync-creatives-async-response-working.json create mode 100644 dist/schemas/2.5.2/media-buy/sync-creatives-request.json create mode 100644 dist/schemas/2.5.2/media-buy/sync-creatives-response.json create mode 100644 dist/schemas/2.5.2/media-buy/update-media-buy-async-response-input-required.json create mode 100644 dist/schemas/2.5.2/media-buy/update-media-buy-async-response-submitted.json create mode 100644 dist/schemas/2.5.2/media-buy/update-media-buy-async-response-working.json create mode 100644 dist/schemas/2.5.2/media-buy/update-media-buy-request.json create mode 100644 dist/schemas/2.5.2/media-buy/update-media-buy-response.json create mode 100644 dist/schemas/2.5.2/pricing-options/cpc-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/cpcv-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/cpm-auction-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/cpm-fixed-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/cpp-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/cpv-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/flat-rate-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/vcpm-auction-option.json create mode 100644 dist/schemas/2.5.2/pricing-options/vcpm-fixed-option.json create mode 100644 dist/schemas/2.5.2/protocols/adcp-extension.json create mode 100644 dist/schemas/2.5.2/signals/activate-signal-request.json create mode 100644 dist/schemas/2.5.2/signals/activate-signal-response.json create mode 100644 dist/schemas/2.5.2/signals/get-signals-request.json create mode 100644 dist/schemas/2.5.2/signals/get-signals-response.json 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 - - - - - - - - -
- -
- Loading... -
- - - - - - - - - - - - diff --git a/server/public/my-content.html b/server/public/my-content.html new file mode 100644 index 0000000000..c09c458a24 --- /dev/null +++ b/server/public/my-content.html @@ -0,0 +1,1248 @@ + + + + + + + My Content - AgenticAdvertising.org + + + + + + + +
+ +
+ Loading... +
+ + + + + + + + + + + + diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 1603c986f9..977cde1bfe 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -354,6 +354,150 @@ export const MEMBER_TOOLS: AddieTool[] = [ }, }, + // ============================================ + // UNIFIED CONTENT MANAGEMENT + // ============================================ + { + name: 'propose_content', + description: + 'Create content for the website (perspectives, committee posts). Content can be for personal perspectives or committee collections. Committee leads and admins can publish directly; others submit for review. Supports co-authors.', + usage_hints: 'use for "write a perspective", "post to the sustainability group", "create an article", "share my thoughts on X"', + input_schema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Content title', + }, + content: { + type: 'string', + description: 'Article content in markdown format (required for article type)', + }, + content_type: { + type: 'string', + enum: ['article', 'link'], + description: 'Type of content. article=original content, link=external link with commentary (default: article)', + }, + external_url: { + type: 'string', + description: 'URL for link type content', + }, + excerpt: { + type: 'string', + description: 'Short excerpt/summary (auto-generated from content if not provided)', + }, + category: { + type: 'string', + description: 'Category for the content', + }, + collection: { + type: 'object', + description: 'Where to publish: personal (perspectives page) or committee (committee page)', + properties: { + type: { + type: 'string', + enum: ['personal', 'committee'], + description: 'Collection type', + }, + committee_slug: { + type: 'string', + description: 'Committee slug (required if type is committee)', + }, + }, + required: ['type'], + }, + co_author_emails: { + type: 'array', + items: { type: 'string' }, + description: 'Email addresses of co-authors to add', + }, + }, + required: ['title', 'collection'], + }, + }, + { + name: 'get_my_content', + description: + 'Get all content where the user is an author, proposer, or owner (committee lead). Shows content across all collections with status and relationship info.', + usage_hints: 'use for "show my content", "my perspectives", "what have I written?", "my pending posts"', + input_schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['draft', 'pending_review', 'published', 'archived', 'rejected', 'all'], + description: 'Filter by status (default: all)', + }, + collection: { + type: 'string', + description: 'Filter by collection: "personal" or a committee slug', + }, + relationship: { + type: 'string', + enum: ['author', 'proposer', 'owner'], + description: 'Filter by relationship type', + }, + }, + required: [], + }, + }, + { + name: 'list_pending_content', + description: + 'List content pending review that the user can approve/reject. Only committee leads see their committee content; admins see all pending content.', + usage_hints: 'use for "what content needs approval?", "pending posts", "review queue"', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'Filter to a specific committee', + }, + }, + required: [], + }, + }, + { + name: 'approve_content', + description: + 'Approve pending content for publication. Only committee leads (for their committees) and admins can approve content.', + usage_hints: 'use for "approve this post", "publish this content"', + input_schema: { + type: 'object', + properties: { + content_id: { + type: 'string', + description: 'The ID of the content to approve', + }, + publish_immediately: { + type: 'boolean', + description: 'Whether to publish immediately (default: true) or save as draft', + }, + }, + required: ['content_id'], + }, + }, + { + name: 'reject_content', + description: + 'Reject pending content with a reason. Only committee leads (for their committees) and admins can reject content. The proposer will see the rejection reason.', + usage_hints: 'use for "reject this post", "decline this content"', + input_schema: { + type: 'object', + properties: { + content_id: { + type: 'string', + description: 'The ID of the content to reject', + }, + reason: { + type: 'string', + description: 'Reason for rejection (required - helps the author understand and improve)', + }, + }, + required: ['content_id', 'reason'], + }, + }, + // ============================================ // ACCOUNT LINKING // ============================================ @@ -1183,6 +1327,330 @@ export function createMemberToolHandlers( return `✅ Post created successfully in the "${slug}" working group!\n\n**Title:** ${title}\n\nYour post is now visible to other working group members.`; }); + // ============================================ + // UNIFIED CONTENT MANAGEMENT + // ============================================ + handlers.set('propose_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to create content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const title = input.title as string; + const contentBody = input.content as string | undefined; + const contentType = (input.content_type as string) || 'article'; + const externalUrl = input.external_url as string | undefined; + const excerpt = input.excerpt as string | undefined; + const category = input.category as string | undefined; + const collection = input.collection as { type: string; committee_slug?: string }; + const coAuthorEmails = input.co_author_emails as string[] | undefined; + + // Validate requirements + if (contentType === 'article' && !contentBody) { + return 'Content is required for article type. Please provide the content in markdown format.'; + } + if (contentType === 'link' && !externalUrl) { + return 'A URL is required for link type content. Please provide the external_url.'; + } + if (collection.type === 'committee' && !collection.committee_slug) { + return 'committee_slug is required when targeting a committee collection.'; + } + + // Build request body + const body: Record = { + title, + content: contentBody, + content_type: contentType, + external_url: externalUrl, + excerpt, + category, + collection, + }; + + // Handle co-authors by looking up user IDs from emails + if (coAuthorEmails && coAuthorEmails.length > 0) { + // For now, co-authors are added via a separate call after content creation + // We'll note them in the response + } + + const result = await callApi('POST', '/api/content/propose', memberContext, body); + + if (!result.ok) { + if (result.status === 404) { + return `Committee "${collection.committee_slug}" not found. Use list_working_groups to see available committees.`; + } + return `Failed to create content: ${result.error}`; + } + + const data = result.data as { id: string; slug: string; status: string; message: string }; + + let response = `## Content ${data.status === 'published' ? 'Published' : 'Submitted'}\n\n`; + response += `**Title:** ${title}\n`; + response += `**Status:** ${data.status === 'published' ? '✅ Published' : '⏳ Pending Review'}\n`; + + if (collection.type === 'committee') { + response += `**Collection:** ${collection.committee_slug}\n`; + } else { + response += `**Collection:** Personal (perspectives)\n`; + } + + if (data.status === 'published') { + if (collection.type === 'committee') { + response += `\n**View:** https://agenticadvertising.org/committees/${collection.committee_slug}\n`; + } else { + response += `\n**View:** https://agenticadvertising.org/perspectives/${data.slug}\n`; + } + } else { + response += `\n_A committee lead or admin will review your submission. You'll be notified when it's approved._\n`; + } + + if (coAuthorEmails && coAuthorEmails.length > 0) { + response += `\n💡 **Note:** To add co-authors, you can edit this content at: https://agenticadvertising.org/admin/content/${data.id}`; + } + + return response; + }); + + handlers.set('get_my_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to see your content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const status = input.status as string | undefined; + const collection = input.collection as string | undefined; + const relationship = input.relationship as string | undefined; + + // Build query string + const params = new URLSearchParams(); + if (status && status !== 'all') params.set('status', status); + if (collection) params.set('collection', collection); + if (relationship) params.set('relationship', relationship); + + const queryString = params.toString() ? `?${params.toString()}` : ''; + const result = await callApi('GET', `/api/me/content${queryString}`, memberContext); + + if (!result.ok) { + return `Failed to fetch your content: ${result.error}`; + } + + const data = result.data as { + items: Array<{ + id: string; + slug: string; + title: string; + status: string; + content_type: string; + collection: { type: string; committee_name?: string; committee_slug?: string }; + relationships: string[]; + authors: Array<{ display_name: string }>; + published_at?: string; + created_at: string; + }>; + }; + + if (data.items.length === 0) { + let response = "You don't have any content yet.\n\n"; + response += 'Use `propose_content` to create your first article or perspective!'; + return response; + } + + let response = `## Your Content\n\n`; + + // Group by status + const byStatus: Record = {}; + for (const item of data.items) { + if (!byStatus[item.status]) byStatus[item.status] = []; + byStatus[item.status].push(item); + } + + // Display order: pending_review first, then published, then others + const statusOrder = ['pending_review', 'published', 'draft', 'rejected', 'archived']; + const statusEmoji: Record = { + pending_review: '⏳', + published: '✅', + draft: '📝', + rejected: '❌', + archived: '📦', + }; + const statusLabel: Record = { + pending_review: 'Pending Review', + published: 'Published', + draft: 'Drafts', + rejected: 'Rejected', + archived: 'Archived', + }; + + for (const statusKey of statusOrder) { + const items = byStatus[statusKey]; + if (!items || items.length === 0) continue; + + response += `### ${statusEmoji[statusKey] || ''} ${statusLabel[statusKey] || statusKey} (${items.length})\n\n`; + + for (const item of items) { + const collectionLabel = item.collection.type === 'committee' + ? `📁 ${item.collection.committee_name || item.collection.committee_slug}` + : '📁 Personal'; + const roleLabels = item.relationships.map(r => { + if (r === 'author') return '✍️ Author'; + if (r === 'proposer') return '📤 Proposer'; + if (r === 'owner') return '👑 Owner'; + return r; + }).join(' | '); + + response += `**${item.title}**\n`; + response += `${collectionLabel} | ${roleLabels}\n`; + if (item.authors.length > 1) { + response += `_Co-authors: ${item.authors.map(a => a.display_name).join(', ')}_\n`; + } + if (item.published_at) { + response += `_Published: ${new Date(item.published_at).toLocaleDateString()}_\n`; + } + response += `\n`; + } + } + + return response; + }); + + handlers.set('list_pending_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to see pending content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const committeeSlug = input.committee_slug as string | undefined; + const queryString = committeeSlug ? `?committee_slug=${encodeURIComponent(committeeSlug)}` : ''; + + const result = await callApi('GET', `/api/content/pending${queryString}`, memberContext); + + if (!result.ok) { + return `Failed to fetch pending content: ${result.error}`; + } + + const data = result.data as { + items: Array<{ + id: string; + title: string; + slug: string; + excerpt?: string; + content_type: string; + proposer: { id: string; name: string }; + proposed_at: string; + collection: { type: string; committee_name?: string; committee_slug?: string }; + authors: Array<{ display_name: string }>; + }>; + summary: { + total: number; + by_collection: Record; + }; + }; + + if (data.items.length === 0) { + return '✅ No pending content to review! All caught up.'; + } + + let response = `## Pending Content for Review\n\n`; + response += `**Total:** ${data.summary.total} item(s)\n\n`; + + // Show breakdown by collection + if (Object.keys(data.summary.by_collection).length > 1) { + response += `**By collection:**\n`; + for (const [col, count] of Object.entries(data.summary.by_collection)) { + const label = col === 'personal' ? 'Personal perspectives' : col; + response += `- ${label}: ${count}\n`; + } + response += `\n`; + } + + for (const item of data.items) { + const collectionLabel = item.collection.type === 'committee' + ? `📁 ${item.collection.committee_name || item.collection.committee_slug}` + : '📁 Personal'; + const proposedDate = new Date(item.proposed_at).toLocaleDateString(); + + response += `---\n\n`; + response += `### ${item.title}\n`; + response += `**ID:** \`${item.id}\`\n`; + response += `${collectionLabel} | Proposed by ${item.proposer.name} on ${proposedDate}\n`; + if (item.excerpt) { + response += `\n_${item.excerpt}_\n`; + } + response += `\n**Actions:** \`approve_content\` or \`reject_content\` with content_id: \`${item.id}\`\n\n`; + } + + return response; + }); + + handlers.set('approve_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to approve content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const contentId = input.content_id as string; + const publishImmediately = input.publish_immediately !== false; // default true + + const result = await callApi( + 'POST', + `/api/content/${contentId}/approve`, + memberContext, + { publish_immediately: publishImmediately } + ); + + if (!result.ok) { + if (result.status === 403) { + return 'Permission denied. Only committee leads and admins can approve content.'; + } + if (result.status === 404) { + return `Content not found with ID: ${contentId}`; + } + if (result.status === 400) { + return `This content is not pending review. It may have already been processed.`; + } + return `Failed to approve content: ${result.error}`; + } + + const data = result.data as { status: string; message: string }; + + if (publishImmediately) { + return `✅ Content approved and published! The author will be notified.`; + } else { + return `✅ Content approved and saved as draft. The author can publish when ready.`; + } + }); + + handlers.set('reject_content', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to reject content. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const contentId = input.content_id as string; + const reason = input.reason as string; + + if (!reason) { + return 'A reason is required when rejecting content. This helps the author understand and improve.'; + } + + const result = await callApi( + 'POST', + `/api/content/${contentId}/reject`, + memberContext, + { reason } + ); + + if (!result.ok) { + if (result.status === 403) { + return 'Permission denied. Only committee leads and admins can reject content.'; + } + if (result.status === 404) { + return `Content not found with ID: ${contentId}`; + } + if (result.status === 400) { + return `This content is not pending review. It may have already been processed.`; + } + return `Failed to reject content: ${result.error}`; + } + + return `❌ Content rejected. The author will see the following reason:\n\n> ${reason}\n\nThey can revise and resubmit if appropriate.`; + }); + // ============================================ // ACCOUNT LINKING // ============================================ diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index 40259b3752..49cd49a006 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -14,6 +14,7 @@ import { AddieDatabase } from '../db/addie-db.js'; import { getThreadService } from './thread-service.js'; import { workos } from '../auth/workos-client.js'; import { logger } from '../logger.js'; +import { getPool } from '../db/client.js'; const slackDb = new SlackDatabase(); const memberDb = new MemberDatabase(); @@ -22,6 +23,62 @@ const workingGroupDb = new WorkingGroupDatabase(); const emailPrefsDb = new EmailPreferencesDatabase(); const addieDb = new AddieDatabase(); +/** + * Get pending content count for a user + * Returns counts for committee leads (their committees) and admins (all) + */ +async function getPendingContentForUser( + workosUserId: string, + isAdmin: boolean +): Promise<{ total: number; by_committee: Record }> { + const pool = getPool(); + + // Get committees user leads + const leaderResult = await pool.query( + `SELECT wg.id, wg.name, wg.slug + FROM working_group_leaders wgl + JOIN working_groups wg ON wg.id = wgl.working_group_id + WHERE wgl.user_id = $1`, + [workosUserId] + ); + const ledCommitteeIds = leaderResult.rows.map(c => c.id); + + if (!isAdmin && ledCommitteeIds.length === 0) { + return { total: 0, by_committee: {} }; + } + + // Build query for pending content + let query = ` + SELECT wg.slug as committee_slug, COUNT(*) as count + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.status = 'pending_review' + `; + const params: (string | string[])[] = []; + + if (!isAdmin) { + // Non-admins only see pending for committees they lead + params.push(ledCommitteeIds); + query += ` AND p.working_group_id = ANY($${params.length})`; + } + + query += ` GROUP BY wg.slug`; + + const result = await pool.query<{ committee_slug: string | null; count: string }>(query, params); + + const byCommittee: Record = {}; + let total = 0; + + for (const row of result.rows) { + const key = row.committee_slug || 'personal'; + const count = parseInt(row.count, 10); + byCommittee[key] = count; + total += count; + } + + return { total, by_committee: byCommittee }; +} + // Cache for member context to avoid repeated lookups for the same user // TTL of 30 minutes - user profile data rarely changes, and we invalidate on specific events const MEMBER_CONTEXT_CACHE_TTL_MS = 30 * 60 * 1000; @@ -163,6 +220,12 @@ export interface MemberContext { last_interaction_at: Date | null; recent_topics: string[]; }; + + /** Pending content the user can review (committee leads and admins) */ + pending_content?: { + total: number; + by_committee: Record; + }; } /** @@ -397,6 +460,23 @@ export async function getMemberContext(slackUserId: string): Promise wg.is_leader) + .map(wg => wg.name) || []; + const userIsAdmin = context.org_membership?.role === 'admin'; + + if (ledCommitteeIds.length > 0 || userIsAdmin) { + try { + const pendingContent = await getPendingContentForUser(workosUserId, userIsAdmin); + if (pendingContent.total > 0) { + context.pending_content = pendingContent; + } + } catch (error) { + logger.warn({ error, workosUserId }, 'Addie: Failed to get pending content'); + } + } + logger.debug( { slackUserId, @@ -648,6 +728,21 @@ export async function getWebMemberContext(workosUserId: string): Promise wg.is_leader) || []; + const webUserIsAdmin = context.org_membership?.role === 'admin'; + + if (leadsCommittees.length > 0 || webUserIsAdmin) { + try { + const pendingContent = await getPendingContentForUser(workosUserId, webUserIsAdmin); + if (pendingContent.total > 0) { + context.pending_content = pendingContent; + } + } catch (error) { + logger.warn({ error, workosUserId }, 'Addie Web: Failed to get pending content'); + } + } + logger.debug( { workosUserId, @@ -825,6 +920,19 @@ export function formatMemberContextForPrompt(context: MemberContext, channel: 'w lines.push('Note: This user\'s Slack account is not yet linked to their AgenticAdvertising.org account.'); } + // Pending content notifications (for committee leads and admins) + if (context.pending_content && context.pending_content.total > 0) { + lines.push(''); + lines.push('### Action Required: Pending Content'); + lines.push(`There are ${context.pending_content.total} content item(s) awaiting your review.`); + for (const [committee, count] of Object.entries(context.pending_content.by_committee)) { + const label = committee === 'personal' ? 'Personal perspectives' : committee; + lines.push(`- ${label}: ${count}`); + } + lines.push(''); + lines.push('IMPORTANT: Proactively mention this pending content to the user. Use `list_pending_content` to show details when relevant, or if the user asks about pending items.'); + } + // Note: Previous Addie interactions removed - Slack Assistant threads handle conversation context automatically lines.push(''); diff --git a/server/src/db/migrations/152_unified_content_management.sql b/server/src/db/migrations/152_unified_content_management.sql new file mode 100644 index 0000000000..da4712b8ba --- /dev/null +++ b/server/src/db/migrations/152_unified_content_management.sql @@ -0,0 +1,165 @@ +-- Migration: 150_unified_content_management.sql +-- Unified content management system with co-authoring, ownership, and proposal workflow +-- See specs/unified-content-management.md for full spec + +-- ============================================================================= +-- 1. Create content_authors table for co-authoring support +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS content_authors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + perspective_id UUID NOT NULL REFERENCES perspectives(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, -- WorkOS user ID + display_name VARCHAR(255) NOT NULL, + display_title VARCHAR(255), + display_order INTEGER DEFAULT 0, -- For author ordering (0 = primary) + created_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(perspective_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_content_authors_perspective ON content_authors(perspective_id); +CREATE INDEX IF NOT EXISTS idx_content_authors_user ON content_authors(user_id); + +COMMENT ON TABLE content_authors IS 'Authors of content items - supports multiple co-authors per perspective'; +COMMENT ON COLUMN content_authors.display_order IS 'Lower numbers appear first; 0 = primary author'; + +-- ============================================================================= +-- 2. Add proposer tracking and review workflow columns to perspectives +-- ============================================================================= + +ALTER TABLE perspectives + ADD COLUMN IF NOT EXISTS proposer_user_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS proposed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS reviewed_by_user_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS rejection_reason TEXT; + +-- Create indexes for new columns +CREATE INDEX IF NOT EXISTS idx_perspectives_proposer ON perspectives(proposer_user_id); +CREATE INDEX IF NOT EXISTS idx_perspectives_proposed_at ON perspectives(proposed_at); + +COMMENT ON COLUMN perspectives.proposer_user_id IS 'User who originally proposed/submitted this content'; +COMMENT ON COLUMN perspectives.proposed_at IS 'When content was submitted for review'; +COMMENT ON COLUMN perspectives.reviewed_by_user_id IS 'User who approved/rejected this content'; +COMMENT ON COLUMN perspectives.reviewed_at IS 'When content was reviewed'; +COMMENT ON COLUMN perspectives.rejection_reason IS 'Reason given when content was rejected'; + +-- ============================================================================= +-- 3. Update status constraint to include new states +-- ============================================================================= + +-- Drop existing constraint and add new one with pending_review and rejected +ALTER TABLE perspectives + DROP CONSTRAINT IF EXISTS perspectives_status_check; + +ALTER TABLE perspectives + ADD CONSTRAINT perspectives_status_check + CHECK (status IN ('draft', 'pending_review', 'published', 'archived', 'rejected')); + +-- ============================================================================= +-- 4. Backfill existing data +-- ============================================================================= + +-- Set proposer_user_id from author_user_id where not already set +UPDATE perspectives +SET proposer_user_id = author_user_id +WHERE proposer_user_id IS NULL AND author_user_id IS NOT NULL; + +-- Create content_authors records from existing author data +-- This preserves backward compatibility while enabling co-authoring +INSERT INTO content_authors (perspective_id, user_id, display_name, display_title, display_order) +SELECT + p.id, + p.author_user_id, + COALESCE(p.author_name, 'Unknown'), + p.author_title, + 0 -- Primary author +FROM perspectives p +WHERE p.author_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM content_authors ca + WHERE ca.perspective_id = p.id AND ca.user_id = p.author_user_id + ); + +-- ============================================================================= +-- 5. Create helper view for content with authors +-- ============================================================================= + +CREATE OR REPLACE VIEW content_with_authors AS +SELECT + p.*, + COALESCE( + (SELECT json_agg( + json_build_object( + 'user_id', ca.user_id, + 'display_name', ca.display_name, + 'display_title', ca.display_title, + 'display_order', ca.display_order + ) ORDER BY ca.display_order + ) + FROM content_authors ca + WHERE ca.perspective_id = p.id), + '[]'::json + ) AS authors_json +FROM perspectives p; + +COMMENT ON VIEW content_with_authors IS 'Perspectives with aggregated authors as JSON'; + +-- ============================================================================= +-- 6. Create function to check if user is committee lead +-- ============================================================================= + +CREATE OR REPLACE FUNCTION is_committee_lead(committee_id UUID, user_id VARCHAR) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM working_group_leaders wgl + WHERE wgl.working_group_id = committee_id + AND wgl.user_id = is_committee_lead.user_id + ); +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION is_committee_lead IS 'Check if a user is a leader of a given committee/working group'; + +-- ============================================================================= +-- 7. Create function to check content ownership +-- ============================================================================= + +CREATE OR REPLACE FUNCTION is_content_owner(perspective_id UUID, user_id VARCHAR) +RETURNS BOOLEAN AS $$ +DECLARE + v_working_group_id UUID; + v_proposer_user_id VARCHAR; +BEGIN + -- Get the perspective details + SELECT p.working_group_id, p.proposer_user_id + INTO v_working_group_id, v_proposer_user_id + FROM perspectives p + WHERE p.id = perspective_id; + + -- Not found + IF v_working_group_id IS NULL AND v_proposer_user_id IS NULL THEN + RETURN FALSE; + END IF; + + -- Committee content: check if user is a lead + IF v_working_group_id IS NOT NULL THEN + RETURN is_committee_lead(v_working_group_id, user_id); + END IF; + + -- Personal content: check if user is the proposer or an author + RETURN v_proposer_user_id = user_id OR EXISTS ( + SELECT 1 FROM content_authors ca + WHERE ca.perspective_id = is_content_owner.perspective_id + AND ca.user_id = is_content_owner.user_id + ); +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION is_content_owner IS 'Check if a user owns/controls a piece of content'; + +-- ============================================================================= +-- Done +-- ============================================================================= diff --git a/server/src/db/migrations/153_public_content_collections.sql b/server/src/db/migrations/153_public_content_collections.sql new file mode 100644 index 0000000000..ee6066782c --- /dev/null +++ b/server/src/db/migrations/153_public_content_collections.sql @@ -0,0 +1,58 @@ +-- Migration: 151_public_content_collections.sql +-- Add support for site-wide content collections that anyone can submit to +-- Examples: Perspectives, Learn Agentic (Training & Education) + +-- ============================================================================= +-- 1. Add accepts_public_submissions flag to working_groups +-- ============================================================================= + +ALTER TABLE working_groups + ADD COLUMN IF NOT EXISTS accepts_public_submissions BOOLEAN DEFAULT FALSE; + +COMMENT ON COLUMN working_groups.accepts_public_submissions IS + 'If true, any authenticated user can submit content to this group (requires lead approval)'; + +-- ============================================================================= +-- 2. Create Editorial working group for site-wide Perspectives +-- ============================================================================= + +INSERT INTO working_groups (slug, name, description, accepts_public_submissions) +VALUES ( + 'editorial', + 'Editorial', + 'Site-wide perspectives and thought leadership content. Anyone can submit, editorial team approves.', + TRUE +) +ON CONFLICT (slug) DO UPDATE SET + accepts_public_submissions = TRUE, + description = EXCLUDED.description; + +-- ============================================================================= +-- 3. Mark Training & Education as accepting public submissions +-- ============================================================================= + +UPDATE working_groups +SET accepts_public_submissions = TRUE, + description = COALESCE(description, '') || ' Anyone can submit educational content for review.' +WHERE slug = 'training-education-wg'; + +-- ============================================================================= +-- 4. Create index for efficient lookup of public collections +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS idx_working_groups_public_submissions + ON working_groups(accepts_public_submissions) + WHERE accepts_public_submissions = TRUE; + +-- ============================================================================= +-- 5. Migrate existing personal perspectives to Editorial +-- ============================================================================= + +-- Move any existing perspectives without a working_group_id to Editorial +UPDATE perspectives +SET working_group_id = (SELECT id FROM working_groups WHERE slug = 'editorial') +WHERE working_group_id IS NULL; + +-- ============================================================================= +-- Done +-- ============================================================================= diff --git a/server/src/http.ts b/server/src/http.ts index e850359cb1..82a00737ad 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -58,8 +58,8 @@ import { createPublicBillingRouter } from "./routes/billing-public.js"; import { createOrganizationsRouter } from "./routes/organizations.js"; import { createEventsRouter } from "./routes/events.js"; import { createLatestRouter } from "./routes/latest.js"; -import { decodeHtmlEntities } from "./utils/html-entities.js"; import { createCommitteeRouters } from "./routes/committees.js"; +import { createContentRouter, createMyContentRouter } from "./routes/content.js"; import { sendWelcomeEmail, sendUserSignupEmail, emailDb } from "./notifications/email.js"; import { emailPrefsDb } from "./db/email-preferences-db.js"; import { queuePerspectiveLink, processPendingResources, processRssPerspectives, processCommunityArticles } from "./addie/services/content-curator.js"; @@ -1290,6 +1290,14 @@ export class HTTPServer { }); this.app.get('/dashboard/emails', (req, res) => serveDashboardPage(req, res, 'dashboard-emails.html')); + // My Content - unified CMS for all authenticated users + this.app.get('/my-content', async (req, res) => { + if (this.isAdcpDomain(req)) { + return res.redirect('https://agenticadvertising.org/my-content'); + } + await this.serveHtmlWithConfig(req, res, 'my-content.html'); + }); + // API endpoints // Public config endpoint - returns feature flags and auth state for nav @@ -3239,390 +3247,6 @@ export class HTTPServer { } }); - // ======================================== - // Perspectives Admin Routes - // ======================================== - - // GET /api/admin/perspectives - List all perspectives - this.app.get('/api/admin/perspectives', requireAuth, requireAdmin, async (req, res) => { - try { - const pool = getPool(); - const result = await pool.query( - `SELECT * FROM perspectives - ORDER BY display_order ASC, published_at DESC NULLS LAST, created_at DESC` - ); - - res.json(result.rows); - } catch (error) { - logger.error({ err: error }, 'Get all perspectives error:'); - res.status(500).json({ - error: 'Failed to get perspectives', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // GET /api/admin/perspectives/:id - Get single perspective - this.app.get('/api/admin/perspectives/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - const pool = getPool(); - const result = await pool.query( - 'SELECT * FROM perspectives WHERE id = $1', - [id] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: 'Perspective not found', - message: `No perspective found with id ${id}` - }); - } - - res.json(result.rows[0]); - } catch (error) { - logger.error({ err: error }, 'Get perspective error:'); - res.status(500).json({ - error: 'Failed to get perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // POST /api/admin/perspectives/fetch-url - Fetch URL metadata for auto-fill - this.app.post('/api/admin/perspectives/fetch-url', requireAuth, requireAdmin, async (req, res) => { - try { - const { url } = req.body; - - if (!url) { - return res.status(400).json({ - error: 'URL required', - message: 'Please provide a URL to fetch' - }); - } - - // Fetch the page - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; AgenticAdvertising/1.0)', - 'Accept': 'text/html,application/xhtml+xml' - }, - redirect: 'follow' - }); - - if (!response.ok) { - throw new Error(`Failed to fetch URL: ${response.status}`); - } - - const html = await response.text(); - - // Extract metadata from HTML - const titleMatch = html.match(/]*>([^<]+)<\/title>/i); - const ogTitleMatch = html.match(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i); - const ogDescMatch = html.match(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i); - const descMatch = html.match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+name=["']description["']/i); - const ogSiteMatch = html.match(/]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i) - || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i); - - // Determine title (prefer og:title, then ) - let title = ogTitleMatch?.[1] || titleMatch?.[1] || ''; - title = decodeHtmlEntities(title.trim()); - - // Determine description (prefer og:description, then meta description) - let excerpt = ogDescMatch?.[1] || descMatch?.[1] || ''; - excerpt = decodeHtmlEntities(excerpt.trim()); - - // Site name from og:site_name or parse from URL - let site_name = ogSiteMatch?.[1] || ''; - if (!site_name) { - try { - const parsedUrl = new URL(url); - site_name = parsedUrl.hostname.replace('www.', ''); - // Capitalize first letter - site_name = site_name.charAt(0).toUpperCase() + site_name.slice(1); - } catch { - // ignore URL parse errors - } - } - site_name = decodeHtmlEntities(site_name); - - res.json({ - title, - excerpt, - site_name - }); - - } catch (error) { - logger.error({ err: error }, 'Fetch URL metadata error:'); - res.status(500).json({ - error: 'Failed to fetch URL', - message: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - - // POST /api/admin/perspectives - Create new perspective - this.app.post('/api/admin/perspectives', requireAuth, requireAdmin, async (req, res) => { - try { - const { - slug, - content_type = 'article', - title, - subtitle, - category, - excerpt, - content, - external_url, - external_site_name, - author_name, - author_title, - featured_image_url, - status = 'draft', - published_at, - display_order = 0, - tags = [], - metadata = {}, - } = req.body; - - const validContentTypes = ['article', 'link']; - const validStatuses = ['draft', 'published', 'archived']; - - if (!slug || !title) { - return res.status(400).json({ - error: 'Missing required fields', - message: 'slug and title are required' - }); - } - - if (!validContentTypes.includes(content_type)) { - return res.status(400).json({ - error: 'Invalid content_type', - message: 'content_type must be: article or link' - }); - } - - if (!validStatuses.includes(status)) { - return res.status(400).json({ - error: 'Invalid status', - message: 'status must be: draft, published, or archived' - }); - } - - // Validate content_type requirements - if (content_type === 'link' && !external_url) { - return res.status(400).json({ - error: 'Missing external_url', - message: 'external_url is required for link type perspectives' - }); - } - - const pool = getPool(); - const result = await pool.query( - `INSERT INTO perspectives ( - slug, content_type, title, subtitle, category, excerpt, - content, external_url, external_site_name, - author_name, author_title, featured_image_url, - status, published_at, display_order, tags, metadata - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) - RETURNING *`, - [ - slug, content_type, title, subtitle, category, excerpt, - content, external_url, external_site_name, - author_name, author_title, featured_image_url, - status, published_at || null, display_order, tags, metadata - ] - ); - - const perspective = result.rows[0]; - - // Queue external links for Addie's knowledge base when published - if (perspective.content_type === 'link' && perspective.status === 'published' && perspective.external_url) { - queuePerspectiveLink({ - id: perspective.id, - title: perspective.title, - external_url: perspective.external_url, - category: perspective.category || 'perspective', - tags: perspective.tags, - }).catch(err => { - logger.warn({ err, perspectiveId: perspective.id }, 'Failed to queue perspective link for indexing'); - }); - } - - res.json(perspective); - } catch (error) { - logger.error({ err: error }, 'Create perspective error:'); - // Check for unique constraint violation - if (error instanceof Error && error.message.includes('duplicate key')) { - return res.status(400).json({ - error: 'Slug already exists', - message: 'A perspective with this slug already exists' - }); - } - res.status(500).json({ - error: 'Failed to create perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // PUT /api/admin/perspectives/:id - Update perspective - this.app.put('/api/admin/perspectives/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - const { - slug, - content_type, - title, - subtitle, - category, - excerpt, - content, - external_url, - external_site_name, - author_name, - author_title, - featured_image_url, - status, - published_at, - display_order, - tags, - metadata, - } = req.body; - - const validContentTypes = ['article', 'link']; - const validStatuses = ['draft', 'published', 'archived']; - - if (!slug || !title) { - return res.status(400).json({ - error: 'Missing required fields', - message: 'slug and title are required' - }); - } - - if (content_type && !validContentTypes.includes(content_type)) { - return res.status(400).json({ - error: 'Invalid content_type', - message: 'content_type must be: article or link' - }); - } - - if (status && !validStatuses.includes(status)) { - return res.status(400).json({ - error: 'Invalid status', - message: 'status must be: draft, published, or archived' - }); - } - - // Validate content_type requirements - if (content_type === 'link' && !external_url) { - return res.status(400).json({ - error: 'Missing external_url', - message: 'external_url is required for link type perspectives' - }); - } - - const pool = getPool(); - const result = await pool.query( - `UPDATE perspectives SET - slug = $1, - content_type = $2, - title = $3, - subtitle = $4, - category = $5, - excerpt = $6, - content = $7, - external_url = $8, - external_site_name = $9, - author_name = $10, - author_title = $11, - featured_image_url = $12, - status = $13, - published_at = $14, - display_order = $15, - tags = $16, - metadata = $17 - WHERE id = $18 - RETURNING *`, - [ - slug, content_type, title, subtitle, category, excerpt, - content, external_url, external_site_name, - author_name, author_title, featured_image_url, - status, published_at || null, display_order, tags, metadata, - id - ] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: 'Perspective not found', - message: `No perspective found with id ${id}` - }); - } - - const perspective = result.rows[0]; - - // Queue external links for indexing when perspective is published - if (perspective.content_type === 'link' && perspective.status === 'published' && perspective.external_url) { - queuePerspectiveLink({ - id: perspective.id, - title: perspective.title, - external_url: perspective.external_url, - category: perspective.category || 'perspective', - tags: perspective.tags, - }).catch(err => { - logger.warn({ err, perspectiveId: perspective.id }, 'Failed to queue perspective link for indexing'); - }); - } - - res.json(perspective); - } catch (error) { - logger.error({ err: error }, 'Update perspective error:'); - // Check for unique constraint violation - if (error instanceof Error && error.message.includes('duplicate key')) { - return res.status(400).json({ - error: 'Slug already exists', - message: 'A perspective with this slug already exists' - }); - } - res.status(500).json({ - error: 'Failed to update perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // DELETE /api/admin/perspectives/:id - Delete perspective - this.app.delete('/api/admin/perspectives/:id', requireAuth, requireAdmin, async (req, res) => { - try { - const { id } = req.params; - const pool = getPool(); - - const result = await pool.query( - 'DELETE FROM perspectives WHERE id = $1 RETURNING id', - [id] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: 'Perspective not found', - message: `No perspective found with id ${id}` - }); - } - - res.json({ success: true, deleted: id }); - } catch (error) { - logger.error({ err: error }, 'Delete perspective error:'); - res.status(500).json({ - error: 'Failed to delete perspective', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - // ======================================== // Committee Routes (Working Groups, Councils, Chapters) // ======================================== @@ -3632,6 +3256,13 @@ export class HTTPServer { this.app.use('/api/working-groups', publicApiRouter); this.app.use('/api/me/working-groups', userApiRouter); + // ======================================== + // Unified Content Management Routes + // ======================================== + + this.app.use('/api/content', createContentRouter()); + this.app.use('/api/me/content', createMyContentRouter()); + // ======================================== // SEO Routes (sitemap.xml, robots.txt) // ======================================== @@ -3898,8 +3529,9 @@ Disallow: /api/admin/ // Note: /admin/billing is now served from billing.ts router - this.app.get('/admin/perspectives', requireAuth, requireAdmin, async (req, res) => { - await this.serveHtmlWithConfig(req, res, 'admin-perspectives.html'); + // Redirect old admin perspectives to unified CMS + this.app.get('/admin/perspectives', requireAuth, requireAdmin, (req, res) => { + res.redirect(301, '/my-content'); }); this.app.get('/admin/working-groups', requireAuth, requireAdmin, async (req, res) => { diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts new file mode 100644 index 0000000000..79ba41bca4 --- /dev/null +++ b/server/src/routes/content.ts @@ -0,0 +1,1061 @@ +/** + * Content routes module + * + * Unified content management routes: + * - Propose content to any collection + * - View pending content for review + * - Approve/reject pending content + * - Get user's content (My Content view) + */ + +import { Router } from 'express'; +import { createLogger } from '../logger.js'; +import { requireAuth } from '../middleware/auth.js'; +import { getPool } from '../db/client.js'; + +const logger = createLogger('content-routes'); + +interface ContentAuthor { + user_id: string; + display_name: string; + display_title?: string; + display_order?: number; +} + +interface ProposeContentRequest { + title: string; + content?: string; + content_type?: 'article' | 'link'; + external_url?: string; + external_site_name?: string; + excerpt?: string; + category?: string; + tags?: string[]; + collection: { + type?: 'personal' | 'committee'; // Deprecated - kept for backwards compatibility + committee_slug?: string; + slug?: string; // New format - collection slug directly + }; + authors?: ContentAuthor[]; +} + +/** + * Check if user is a committee lead + */ +async function isCommitteeLead(committeeId: string, userId: string): Promise<boolean> { + const pool = getPool(); + const result = await pool.query( + `SELECT 1 FROM working_group_leaders + WHERE working_group_id = $1 AND user_id = $2`, + [committeeId, userId] + ); + return result.rows.length > 0; +} + +/** + * Check if user is a site admin + */ +async function isAdmin(userId: string): Promise<boolean> { + const pool = getPool(); + const result = await pool.query( + `SELECT 1 FROM users + WHERE id = $1 AND is_aao_admin = true`, + [userId] + ); + return result.rows.length > 0; +} + +/** + * Get user info for author display + */ +async function getUserInfo(userId: string): Promise<{ name: string; title?: string } | null> { + const pool = getPool(); + const result = await pool.query( + `SELECT first_name, last_name, title, email FROM users WHERE id = $1`, + [userId] + ); + if (result.rows.length === 0) return null; + const user = result.rows[0]; + const name = user.first_name && user.last_name + ? `${user.first_name} ${user.last_name}` + : user.email?.split('@')[0] || 'Unknown'; + return { name, title: user.title }; +} + +/** + * Create content routes + * Returns a router to be mounted at /api/content + */ +export function createContentRouter(): Router { + const router = Router(); + + // GET /api/content/collections - Get available collections for content submission + router.get('/collections', requireAuth, async (req, res) => { + try { + const user = req.user!; + const pool = getPool(); + + // Get public collections (anyone can submit) + const publicResult = await pool.query( + `SELECT id, slug, name, description + FROM working_groups + WHERE accepts_public_submissions = TRUE + ORDER BY name` + ); + + // Get committees user is a member of (non-public ones) + const memberResult = await pool.query( + `SELECT wg.id, wg.slug, wg.name, wg.description, + EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) as is_leader + FROM working_group_memberships wgm + JOIN working_groups wg ON wg.id = wgm.working_group_id + WHERE wgm.workos_user_id = $1 + AND wg.accepts_public_submissions = FALSE + ORDER BY wg.name`, + [user.id] + ); + + const collections = [ + ...publicResult.rows.map(row => ({ + slug: row.slug, + name: row.name, + description: row.description, + type: 'public' as const, + can_publish_directly: false, // Public collections always require approval + })), + ...memberResult.rows.map(row => ({ + slug: row.slug, + name: row.name, + description: row.description, + type: 'committee' as const, + can_publish_directly: row.is_leader, + })), + ]; + + res.json({ collections }); + } catch (error) { + logger.error({ err: error }, 'GET /api/content/collections error'); + res.status(500).json({ + error: 'Failed to get collections', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/content/propose - Submit content to any collection + router.post('/propose', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { + title, + content, + content_type = 'article', + external_url, + external_site_name, + excerpt, + category, + tags = [], + collection, + authors, + } = req.body as ProposeContentRequest; + + // Validate required fields + if (!title) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'title is required', + }); + } + + // Support both old format (collection.type + committee_slug) and new format (just committee_slug) + const committeeSlug = collection?.committee_slug || collection?.slug; + if (!committeeSlug) { + return res.status(400).json({ + error: 'Missing collection', + message: 'collection.committee_slug or collection.slug is required', + }); + } + + // Validate content_type requirements + if (content_type === 'link' && !external_url) { + return res.status(400).json({ + error: 'Missing external_url', + message: 'external_url is required for link type content', + }); + } + + if (content_type === 'article' && !content) { + return res.status(400).json({ + error: 'Missing content', + message: 'content is required for article type content', + }); + } + + const pool = getPool(); + + // Resolve the collection (working group) + const committeeResult = await pool.query( + `SELECT id, accepts_public_submissions FROM working_groups WHERE slug = $1`, + [committeeSlug] + ); + + if (committeeResult.rows.length === 0) { + return res.status(404).json({ + error: 'Collection not found', + message: `No collection found with slug: ${committeeSlug}`, + }); + } + + const committeeId = committeeResult.rows[0].id as string; + const acceptsPublicSubmissions = committeeResult.rows[0].accepts_public_submissions; + + // Check if user can submit to this collection + const userIsLead = await isCommitteeLead(committeeId, user.id); + const userIsAdmin = await isAdmin(user.id); + + // For non-public collections, user must be a member + if (!acceptsPublicSubmissions && !userIsLead && !userIsAdmin) { + const membershipResult = await pool.query( + `SELECT 1 FROM working_group_memberships WHERE working_group_id = $1 AND workos_user_id = $2`, + [committeeId, user.id] + ); + if (membershipResult.rows.length === 0) { + return res.status(403).json({ + error: 'Not a member', + message: 'You must be a member of this committee to submit content', + }); + } + } + + // Determine if user can publish directly (leads and admins only) + const canPublishDirectly = userIsLead || userIsAdmin; + + // Generate slug from title + const baseSlug = title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .substring(0, 100); + const slug = `${baseSlug}-${Date.now().toString(36)}`; + + // Determine initial status + const status = canPublishDirectly ? 'published' : 'pending_review'; + const publishedAt = canPublishDirectly ? new Date().toISOString() : null; + const proposedAt = new Date().toISOString(); + + // Get author info for display + const userInfo = await getUserInfo(user.id); + const authorName = userInfo?.name || user.email?.split('@')[0] || 'Unknown'; + const authorTitle = userInfo?.title; + + // Insert the content + const result = await pool.query( + `INSERT INTO perspectives ( + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + author_name, author_title, author_user_id, + proposer_user_id, proposed_at, + working_group_id, status, published_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING *`, + [ + slug, content_type, title, content, excerpt, + external_url, external_site_name, category, tags, + authorName, authorTitle, user.id, + user.id, proposedAt, + committeeId, status, publishedAt, + ] + ); + + const perspective = result.rows[0]; + + // Create content_authors records + const authorsToCreate = authors && authors.length > 0 + ? authors + : [{ user_id: user.id, display_name: authorName, display_title: authorTitle, display_order: 0 }]; + + for (const author of authorsToCreate) { + await pool.query( + `INSERT INTO content_authors (perspective_id, user_id, display_name, display_title, display_order) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (perspective_id, user_id) DO NOTHING`, + [perspective.id, author.user_id, author.display_name, author.display_title, author.display_order || 0] + ); + } + + logger.info({ + perspectiveId: perspective.id, + userId: user.id, + title, + status, + collection: collection.type, + committeeSlug: collection.committee_slug, + }, 'Content proposed'); + + const message = canPublishDirectly + ? 'Content published successfully' + : 'Content submitted for review. A committee lead or admin will review it soon.'; + + res.status(201).json({ + id: perspective.id, + slug: perspective.slug, + status: perspective.status, + message, + }); + } catch (error) { + logger.error({ err: error }, 'POST /api/content/propose error'); + res.status(500).json({ + error: 'Failed to propose content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // GET /api/content/pending - List pending content user can review + router.get('/pending', requireAuth, async (req, res) => { + try { + const user = req.user!; + const committeeSlug = req.query.committee_slug as string | undefined; + const pool = getPool(); + + // Get committees user leads + const leaderResult = await pool.query( + `SELECT wg.id, wg.name, wg.slug + FROM working_group_leaders wgl + JOIN working_groups wg ON wg.id = wgl.working_group_id + WHERE wgl.user_id = $1`, + [user.id] + ); + const ledCommittees = leaderResult.rows; + const ledCommitteeIds = ledCommittees.map(c => c.id); + + // Check if admin + const userIsAdmin = await isAdmin(user.id); + + if (!userIsAdmin && ledCommitteeIds.length === 0) { + return res.json({ + items: [], + summary: { total: 0, by_collection: {} }, + }); + } + + // Build query for pending content + let query = ` + SELECT + p.id, p.title, p.excerpt, p.slug, p.content_type, + p.proposer_user_id, p.proposed_at, p.working_group_id, + wg.name as committee_name, wg.slug as committee_slug, + u.first_name, u.last_name, u.email as proposer_email, + (SELECT json_agg(json_build_object( + 'user_id', ca.user_id, + 'display_name', ca.display_name + ) ORDER BY ca.display_order) + FROM content_authors ca WHERE ca.perspective_id = p.id) as authors + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + LEFT JOIN users u ON u.id = p.proposer_user_id + WHERE p.status = 'pending_review' + `; + const params: (string | string[])[] = []; + + if (!userIsAdmin) { + // Non-admins only see pending for committees they lead + params.push(ledCommitteeIds); + query += ` AND p.working_group_id = ANY($${params.length})`; + } + + if (committeeSlug) { + params.push(committeeSlug); + query += ` AND wg.slug = $${params.length}`; + } + + query += ` ORDER BY p.proposed_at ASC`; + + const result = await pool.query(query, params); + + // Format response + const items = result.rows.map(row => ({ + id: row.id, + title: row.title, + slug: row.slug, + excerpt: row.excerpt, + content_type: row.content_type, + proposer: { + id: row.proposer_user_id, + name: row.first_name && row.last_name + ? `${row.first_name} ${row.last_name}` + : row.proposer_email?.split('@')[0] || 'Unknown', + }, + proposed_at: row.proposed_at, + collection: { + type: row.working_group_id ? 'committee' : 'personal', + committee_name: row.committee_name, + committee_slug: row.committee_slug, + }, + authors: row.authors || [], + })); + + // Calculate summary + const byCollection: Record<string, number> = {}; + for (const item of items) { + const key = item.collection.committee_slug || 'personal'; + byCollection[key] = (byCollection[key] || 0) + 1; + } + + res.json({ + items, + summary: { + total: items.length, + by_collection: byCollection, + }, + }); + } catch (error) { + logger.error({ err: error }, 'GET /api/content/pending error'); + res.status(500).json({ + error: 'Failed to get pending content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/content/:id/approve - Approve pending content + router.post('/:id/approve', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { publish_immediately = true } = req.body; + const pool = getPool(); + + // Get the content + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const content = contentResult.rows[0]; + + if (content.status !== 'pending_review') { + return res.status(400).json({ + error: 'Invalid status', + message: `Content is not pending review (current status: ${content.status})`, + }); + } + + // Check permission + const userIsAdmin = await isAdmin(user.id); + const userIsLead = content.working_group_id + ? await isCommitteeLead(content.working_group_id, user.id) + : false; + + if (!userIsAdmin && !userIsLead) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to approve this content', + }); + } + + // Update status + const newStatus = publish_immediately ? 'published' : 'draft'; + const publishedAt = publish_immediately ? new Date().toISOString() : null; + + await pool.query( + `UPDATE perspectives + SET status = $1, published_at = $2, + reviewed_by_user_id = $3, reviewed_at = NOW() + WHERE id = $4`, + [newStatus, publishedAt, user.id, id] + ); + + logger.info({ + contentId: id, + reviewerId: user.id, + newStatus, + committeeSlug: content.committee_slug, + }, 'Content approved'); + + res.json({ + success: true, + status: newStatus, + message: publish_immediately + ? 'Content approved and published' + : 'Content approved and saved as draft', + }); + } catch (error) { + logger.error({ err: error }, 'POST /api/content/:id/approve error'); + res.status(500).json({ + error: 'Failed to approve content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/content/fetch-url - Fetch URL metadata for auto-fill + router.post('/fetch-url', requireAuth, async (req, res) => { + try { + const { url } = req.body; + + if (!url) { + return res.status(400).json({ + error: 'URL required', + message: 'Please provide a URL to fetch', + }); + } + + // Fetch the page + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; AgenticAdvertising/1.0)', + 'Accept': 'text/html,application/xhtml+xml', + }, + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`Failed to fetch URL: ${response.status}`); + } + + const html = await response.text(); + + // Extract metadata from HTML + const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); + const ogTitleMatch = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i); + const ogDescMatch = html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i); + const descMatch = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i); + const ogSiteMatch = html.match(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i) + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i); + + // Decode HTML entities helper + const decodeHtmlEntities = (text: string): string => { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec)) + .replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + }; + + // Determine title (prefer og:title, then <title>) + let title = ogTitleMatch?.[1] || titleMatch?.[1] || ''; + title = decodeHtmlEntities(title.trim()); + + // Determine description (prefer og:description, then meta description) + let excerpt = ogDescMatch?.[1] || descMatch?.[1] || ''; + excerpt = decodeHtmlEntities(excerpt.trim()); + + // Site name from og:site_name or parse from URL + let site_name = ogSiteMatch?.[1] || ''; + if (!site_name) { + try { + const parsedUrl = new URL(url); + site_name = parsedUrl.hostname.replace('www.', ''); + // Capitalize first letter + site_name = site_name.charAt(0).toUpperCase() + site_name.slice(1); + } catch { + // ignore URL parse errors + } + } + site_name = decodeHtmlEntities(site_name); + + res.json({ + title, + excerpt, + site_name, + }); + } catch (error) { + logger.error({ err: error }, 'POST /api/content/fetch-url error'); + res.status(500).json({ + error: 'Failed to fetch URL', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/content/:id/reject - Reject pending content + router.post('/:id/reject', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { reason } = req.body; + const pool = getPool(); + + if (!reason) { + return res.status(400).json({ + error: 'Missing reason', + message: 'A reason is required when rejecting content', + }); + } + + // Get the content + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const content = contentResult.rows[0]; + + if (content.status !== 'pending_review') { + return res.status(400).json({ + error: 'Invalid status', + message: `Content is not pending review (current status: ${content.status})`, + }); + } + + // Check permission + const userIsAdmin = await isAdmin(user.id); + const userIsLead = content.working_group_id + ? await isCommitteeLead(content.working_group_id, user.id) + : false; + + if (!userIsAdmin && !userIsLead) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to reject this content', + }); + } + + // Update status + await pool.query( + `UPDATE perspectives + SET status = 'rejected', rejection_reason = $1, + reviewed_by_user_id = $2, reviewed_at = NOW() + WHERE id = $3`, + [reason, user.id, id] + ); + + logger.info({ + contentId: id, + reviewerId: user.id, + reason, + committeeSlug: content.committee_slug, + }, 'Content rejected'); + + res.json({ + success: true, + status: 'rejected', + message: 'Content rejected', + }); + } catch (error) { + logger.error({ err: error }, 'POST /api/content/:id/reject error'); + res.status(500).json({ + error: 'Failed to reject content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} + +/** + * Create user content routes (My Content) + * Returns a router to be mounted at /api/me/content + */ +export function createMyContentRouter(): Router { + const router = Router(); + + // GET /api/me/content - Get all content where user has a relationship + router.get('/', requireAuth, async (req, res) => { + try { + const user = req.user!; + const status = req.query.status as string | undefined; + const collection = req.query.collection as string | undefined; + const relationship = req.query.relationship as string | undefined; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const pool = getPool(); + + // Get committees user leads (for "owner" relationship) + const leaderResult = await pool.query( + `SELECT working_group_id FROM working_group_leaders WHERE user_id = $1`, + [user.id] + ); + const ledCommitteeIds = leaderResult.rows.map(r => r.working_group_id); + + // Build the query + let query = ` + SELECT DISTINCT ON (p.id) + p.id, p.slug, p.content_type, p.title, p.subtitle, p.category, p.excerpt, + p.external_url, p.external_site_name, p.status, p.published_at, + p.created_at, p.updated_at, p.working_group_id, p.proposer_user_id, + wg.name as committee_name, wg.slug as committee_slug, + -- Determine relationships + CASE WHEN p.proposer_user_id = $1 THEN true ELSE false END as is_proposer, + CASE WHEN EXISTS (SELECT 1 FROM content_authors ca WHERE ca.perspective_id = p.id AND ca.user_id = $1) THEN true ELSE false END as is_author, + CASE WHEN p.working_group_id = ANY($2) THEN true ELSE false END as is_lead, + -- Get authors + (SELECT json_agg(json_build_object( + 'user_id', ca.user_id, + 'display_name', ca.display_name, + 'display_title', ca.display_title + ) ORDER BY ca.display_order) + FROM content_authors ca WHERE ca.perspective_id = p.id) as authors + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE ( + p.proposer_user_id = $1 + OR EXISTS (SELECT 1 FROM content_authors ca WHERE ca.perspective_id = p.id AND ca.user_id = $1) + OR p.working_group_id = ANY($2) + ) + `; + const params: (string | string[] | number)[] = [user.id, ledCommitteeIds]; + + // Apply filters + if (status && status !== 'all') { + const validStatuses = ['draft', 'pending_review', 'published', 'archived', 'rejected']; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + error: 'Invalid status', + message: `status must be one of: ${validStatuses.join(', ')}, or 'all'`, + }); + } + params.push(status); + query += ` AND p.status = $${params.length}`; + } + + if (collection) { + if (collection === 'personal') { + query += ` AND p.working_group_id IS NULL`; + } else { + // Assume it's a committee slug + params.push(collection); + query += ` AND wg.slug = $${params.length}`; + } + } + + query += ` ORDER BY p.id, p.created_at DESC`; + params.push(limit); + query += ` LIMIT $${params.length}`; + + const result = await pool.query(query, params); + + // Format response with relationships + const items = result.rows.map(row => { + const relationships: string[] = []; + if (row.is_author) relationships.push('author'); + if (row.is_proposer) relationships.push('proposer'); + if (row.is_lead) relationships.push('owner'); + + return { + id: row.id, + slug: row.slug, + title: row.title, + subtitle: row.subtitle, + content_type: row.content_type, + category: row.category, + excerpt: row.excerpt, + status: row.status, + collection: { + type: row.working_group_id ? 'committee' : 'personal', + committee_name: row.committee_name, + committee_slug: row.committee_slug, + }, + relationships, + authors: row.authors || [], + published_at: row.published_at, + created_at: row.created_at, + updated_at: row.updated_at, + }; + }).filter(item => { + // Apply relationship filter if specified + if (!relationship) return true; + return item.relationships.includes(relationship); + }); + + res.json({ items }); + } catch (error) { + logger.error({ err: error }, 'GET /api/me/content error'); + res.status(500).json({ + error: 'Failed to get content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // PUT /api/me/content/:id - Update content user owns + router.put('/:id', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { + title, + content, + content_type, + excerpt, + external_url, + external_site_name, + category, + tags, + } = req.body; + const pool = getPool(); + + // Get the content and check ownership + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const contentItem = contentResult.rows[0]; + + // Check permission: proposer, author, committee lead, or admin + const isProposer = contentItem.proposer_user_id === user.id; + const isAuthor = await pool.query( + `SELECT 1 FROM content_authors WHERE perspective_id = $1 AND user_id = $2`, + [id, user.id] + ).then(r => r.rows.length > 0); + const userIsLead = contentItem.working_group_id + ? await isCommitteeLead(contentItem.working_group_id, user.id) + : false; + const userIsAdmin = await isAdmin(user.id); + + if (!isProposer && !isAuthor && !userIsLead && !userIsAdmin) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to edit this content', + }); + } + + // Build update query + const updates: string[] = []; + const values: (string | string[] | null)[] = []; + let paramIndex = 1; + + if (title !== undefined) { + updates.push(`title = $${paramIndex++}`); + values.push(title); + } + if (content !== undefined) { + updates.push(`content = $${paramIndex++}`); + values.push(content); + } + if (content_type !== undefined) { + updates.push(`content_type = $${paramIndex++}`); + values.push(content_type); + } + if (excerpt !== undefined) { + updates.push(`excerpt = $${paramIndex++}`); + values.push(excerpt); + } + if (external_url !== undefined) { + updates.push(`external_url = $${paramIndex++}`); + values.push(external_url); + } + if (external_site_name !== undefined) { + updates.push(`external_site_name = $${paramIndex++}`); + values.push(external_site_name); + } + if (category !== undefined) { + updates.push(`category = $${paramIndex++}`); + values.push(category); + } + if (tags !== undefined) { + updates.push(`tags = $${paramIndex++}`); + values.push(tags); + } + + if (updates.length === 0) { + return res.status(400).json({ + error: 'No updates provided', + message: 'Please provide at least one field to update', + }); + } + + values.push(id); + const result = await pool.query( + `UPDATE perspectives SET ${updates.join(', ')}, updated_at = NOW() + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + logger.info({ contentId: id, userId: user.id }, 'Content updated'); + + res.json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'PUT /api/me/content/:id error'); + res.status(500).json({ + error: 'Failed to update content', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/me/content/:id/authors - Add co-author to content + router.post('/:id/authors', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id } = req.params; + const { user_id, display_name, display_title } = req.body; + const pool = getPool(); + + if (!user_id || !display_name) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'user_id and display_name are required', + }); + } + + // Check ownership + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const contentItem = contentResult.rows[0]; + + // Check permission + const isProposer = contentItem.proposer_user_id === user.id; + const userIsLead = contentItem.working_group_id + ? await isCommitteeLead(contentItem.working_group_id, user.id) + : false; + const userIsAdmin = await isAdmin(user.id); + + if (!isProposer && !userIsLead && !userIsAdmin) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to add authors to this content', + }); + } + + // Get current max display_order + const orderResult = await pool.query( + `SELECT COALESCE(MAX(display_order), -1) + 1 as next_order + FROM content_authors WHERE perspective_id = $1`, + [id] + ); + const nextOrder = orderResult.rows[0].next_order; + + // Add the author + const result = await pool.query( + `INSERT INTO content_authors (perspective_id, user_id, display_name, display_title, display_order) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (perspective_id, user_id) DO UPDATE SET + display_name = EXCLUDED.display_name, + display_title = EXCLUDED.display_title + RETURNING *`, + [id, user_id, display_name, display_title, nextOrder] + ); + + logger.info({ contentId: id, authorUserId: user_id }, 'Author added to content'); + + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error({ err: error }, 'POST /api/me/content/:id/authors error'); + res.status(500).json({ + error: 'Failed to add author', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // DELETE /api/me/content/:id/authors/:authorId - Remove co-author from content + router.delete('/:id/authors/:authorId', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { id, authorId } = req.params; + const pool = getPool(); + + // Check ownership + const contentResult = await pool.query( + `SELECT p.*, wg.slug as committee_slug + FROM perspectives p + LEFT JOIN working_groups wg ON wg.id = p.working_group_id + WHERE p.id = $1`, + [id] + ); + + if (contentResult.rows.length === 0) { + return res.status(404).json({ + error: 'Content not found', + message: `No content found with id: ${id}`, + }); + } + + const contentItem = contentResult.rows[0]; + + // Check permission + const isProposer = contentItem.proposer_user_id === user.id; + const userIsLead = contentItem.working_group_id + ? await isCommitteeLead(contentItem.working_group_id, user.id) + : false; + const userIsAdmin = await isAdmin(user.id); + + if (!isProposer && !userIsLead && !userIsAdmin) { + return res.status(403).json({ + error: 'Permission denied', + message: 'You do not have permission to remove authors from this content', + }); + } + + // Remove the author + const result = await pool.query( + `DELETE FROM content_authors WHERE perspective_id = $1 AND user_id = $2 RETURNING *`, + [id, authorId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: 'Author not found', + message: `No author found with id: ${authorId}`, + }); + } + + logger.info({ contentId: id, authorUserId: authorId }, 'Author removed from content'); + + res.json({ success: true, deleted: authorId }); + } catch (error) { + logger.error({ err: error }, 'DELETE /api/me/content/:id/authors/:authorId error'); + res.status(500).json({ + error: 'Failed to remove author', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/specs/unified-content-management.md b/specs/unified-content-management.md new file mode 100644 index 0000000000..d7ebe64a57 --- /dev/null +++ b/specs/unified-content-management.md @@ -0,0 +1,469 @@ +# Unified Content Management System + +## What We're Building + +A content management system that separates authorship (who gets credit) from ownership (who controls), supports co-authoring, and enables content proposals with review workflows. + +--- + +## Core Concepts + +### Authorship vs Ownership vs Proposer + +| Role | What it means | Example | +|------|--------------|---------| +| **Authors** | Who gets display credit | "By Alice Chen and Bob Smith" | +| **Owner** | Who can publish/edit/delete | Committee leads control committee content | +| **Proposer** | Who originally submitted | Tracked for pending items; may differ from authors | + +### Ownership Rules + +``` +Content Type → Owner +───────────────────────────────────────────────────── +Personal perspective → The individual author +Committee content → Committee leads (via working_group_leaders) +Site-wide content → Site admins +``` + +### Content Status Flow + +``` +[draft] ──user creates──→ [pending_review] ──owner approves──→ [published] + │ + └──owner rejects──→ [rejected] +``` + +- Members with ownership rights can skip `pending_review` and publish directly +- Content always starts as `draft` when created, moves to `pending_review` on submission + +--- + +## Data Model Changes + +### 1. New Table: `content_authors` + +Replaces single `author_user_id` with support for multiple co-authors. + +```sql +CREATE TABLE content_authors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + perspective_id UUID NOT NULL REFERENCES perspectives(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, -- WorkOS user ID + display_name VARCHAR(255) NOT NULL, + display_title VARCHAR(255), + display_order INTEGER DEFAULT 0, -- For author ordering + created_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(perspective_id, user_id) +); + +CREATE INDEX idx_content_authors_perspective ON content_authors(perspective_id); +CREATE INDEX idx_content_authors_user ON content_authors(user_id); +``` + +### 2. Modify `perspectives` Table + +```sql +-- Add proposer tracking and new status +ALTER TABLE perspectives + ADD COLUMN proposer_user_id VARCHAR(255), + ADD COLUMN proposed_at TIMESTAMPTZ, + ADD COLUMN reviewed_by_user_id VARCHAR(255), + ADD COLUMN reviewed_at TIMESTAMPTZ, + ADD COLUMN rejection_reason TEXT; + +-- Update status constraint to include new states +ALTER TABLE perspectives + DROP CONSTRAINT IF EXISTS perspectives_status_check, + ADD CONSTRAINT perspectives_status_check + CHECK (status IN ('draft', 'pending_review', 'published', 'archived', 'rejected')); + +-- Backfill: existing author_user_id becomes both proposer and first author +-- (handled in migration) +``` + +### 3. Keep Existing Columns (Backward Compatible) + +- `author_user_id` - deprecated but kept for backward compatibility; will be the "primary" author +- `author_name` / `author_title` - kept for display fallback; computed from first author going forward + +--- + +## Permission Model + +### Who Can Do What + +```typescript +interface ContentPermissions { + // Anyone can propose content to any collection + canPropose: () => boolean; // Always true for authenticated users + + // Ownership determines control + canPublish: (content: Perspective, user: User) => boolean; + canEdit: (content: Perspective, user: User) => boolean; + canDelete: (content: Perspective, user: User) => boolean; +} + +function isOwner(content: Perspective, userId: string): boolean { + // Personal content: user is proposer/author + if (!content.working_group_id) { + return content.proposer_user_id === userId || + content.authors?.some(a => a.user_id === userId); + } + + // Committee content: user is committee lead + return isCommitteeLead(content.working_group_id, userId); +} + +function canPublishDirectly(content: Perspective, userId: string): boolean { + // Site admins can always publish + if (isAdmin(userId)) return true; + + // Committee leads can publish to their committees + if (content.working_group_id) { + return isCommitteeLead(content.working_group_id, userId); + } + + // Personal content: only if no committee (goes to /perspectives page) + // These require admin approval since they're site-wide + return false; +} +``` + +### Permission Matrix + +| Action | Personal Content | Committee Content | Site-wide | +|--------|-----------------|-------------------|-----------| +| Create draft | Author | Any member | Admin | +| Submit for review | Author | Any member | Admin | +| Publish directly | Never (needs admin) | Committee lead | Admin | +| Edit published | Admin | Committee lead | Admin | +| Delete | Admin | Committee lead | Admin | +| View pending | Proposer + Admin | Proposer + Lead | Admin | + +--- + +## API Changes + +### New Endpoints + +``` +POST /api/content/propose + Submit content to any collection (creates in pending_review) + +GET /api/content/pending + List pending content user can review (as owner/lead/admin) + +POST /api/content/:id/approve + Approve pending content (owner/lead/admin only) + +POST /api/content/:id/reject + Reject pending content with reason (owner/lead/admin only) + +GET /api/me/content + "My Content" view - content where user is author, proposer, or owner +``` + +### Propose Content + +```typescript +// POST /api/content/propose +interface ProposeContentRequest { + title: string; + content?: string; // Markdown for articles + content_type: 'article' | 'link'; + external_url?: string; + external_site_name?: string; + excerpt?: string; + category?: string; + + // Where it should appear + collection: { + type: 'personal' | 'committee'; + committee_slug?: string; // Required if type === 'committee' + }; + + // Authors (defaults to current user) + authors?: Array<{ + user_id: string; + display_name: string; + display_title?: string; + }>; +} + +interface ProposeContentResponse { + id: string; + status: 'draft' | 'pending_review' | 'published'; + // If user is owner, status will be 'published' (direct publish) + // Otherwise, status will be 'pending_review' + message: string; +} +``` + +### Get Pending Content + +```typescript +// GET /api/content/pending +interface GetPendingResponse { + items: Array<{ + id: string; + title: string; + excerpt?: string; + proposer: { + id: string; + name: string; + }; + proposed_at: string; + collection: { + type: 'personal' | 'committee'; + committee_name?: string; + committee_slug?: string; + }; + authors: Array<{ + user_id: string; + display_name: string; + }>; + }>; + summary: { + total: number; + by_collection: Record<string, number>; + }; +} +``` + +### My Content View + +```typescript +// GET /api/me/content +interface MyContentResponse { + items: Array<{ + id: string; + title: string; + status: string; + collection: { + type: 'personal' | 'committee'; + committee_name?: string; + }; + // User's relationship to this content + relationships: Array<'author' | 'proposer' | 'owner'>; + published_at?: string; + created_at: string; + }>; +} +``` + +--- + +## Addie Tool Changes + +### New Tools + +```typescript +// For committee leads and admins +{ + name: 'list_pending_content', + description: 'List content pending review that you can approve or reject.', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'Filter to specific committee (optional)' + } + } + } +} + +{ + name: 'approve_content', + description: 'Approve pending content for publication.', + input_schema: { + type: 'object', + properties: { + content_id: { type: 'string' }, + publish_immediately: { type: 'boolean', default: true } + }, + required: ['content_id'] + } +} + +{ + name: 'reject_content', + description: 'Reject pending content with feedback.', + input_schema: { + type: 'object', + properties: { + content_id: { type: 'string' }, + reason: { type: 'string' } + }, + required: ['content_id', 'reason'] + } +} +``` + +### Updated Tools + +```typescript +// Update create_perspective to support proposals +{ + name: 'create_perspective', + description: 'Create a perspective article or link. If targeting a committee you lead, publishes directly. Otherwise, submits for review.', + input_schema: { + type: 'object', + properties: { + title: { type: 'string' }, + content: { type: 'string' }, + content_type: { enum: ['article', 'link'] }, + // New: where to publish + committee_slug: { + type: 'string', + description: 'Committee to publish to. Omit for personal perspective.' + }, + // New: co-authors + co_author_emails: { + type: 'array', + items: { type: 'string' }, + description: 'Email addresses of co-authors to add' + } + }, + required: ['title'] + } +} +``` + +### Proactive Notifications + +Addie surfaces pending content to relevant users: + +```typescript +// In Addie's context building +interface PendingNotification { + type: 'pending_content'; + count: number; + summary: string; // "3 articles pending in Media Buying Protocol" + action: string; // "Use list_pending_content to review" +} + +// Triggers: +// 1. When committee lead opens Addie: check for pending in their committees +// 2. When admin opens Addie: check for pending site-wide content +// 3. Daily digest option (future) +``` + +Example Addie prompts: + +``` +"You have 3 pending posts in Media Buying Protocol committee awaiting review." + +"2 perspective submissions need your approval before they can be published." + +"Alice Chen submitted an article 'The Future of Agentic Ads' to your committee yesterday." +``` + +--- + +## UI Requirements + +### "My Content" Dashboard Section + +Location: `/dashboard` or `/me/content` + +``` +┌─────────────────────────────────────────────────────────────┐ +│ My Content │ +├─────────────────────────────────────────────────────────────┤ +│ [Draft] My thoughts on AI advertising ✏️ 🗑️ │ +│ Personal · You are the author │ +│ │ +│ [Pending] Q4 Media Buying Trends 👁️ │ +│ Media Buying Protocol · You proposed this │ +│ │ +│ [Published] Welcome to NYC Chapter! ✏️ │ +│ NYC Chapter · You are a lead │ +└─────────────────────────────────────────────────────────────┘ +``` + +Badges: +- `author` - "You are an author" +- `proposer` - "You proposed this" +- `owner` - "You are a lead" / "You can manage" + +### Pending Review Queue (for leads/admins) + +Location: Committee manage page or admin dashboard + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Pending Review (3) │ +├─────────────────────────────────────────────────────────────┤ +│ Q4 Media Buying Trends │ +│ By: Alice Chen · Proposed: 2 days ago │ +│ [Preview] [Approve] [Reject] │ +│ │ +│ New Member Welcome Guide │ +│ By: Bob Smith · Proposed: 5 days ago │ +│ [Preview] [Approve] [Reject] │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Content Creation Flow + +When member creates content: + +1. **Select collection**: "Where should this appear?" + - My personal perspectives + - [List of committees they're members of] + +2. **Add co-authors** (optional): Search by name/email + +3. **Submit**: + - If they're an owner (committee lead): "Publish now" or "Save as draft" + - If they're not an owner: "Submit for review" (goes to pending_review) + +--- + +## Migration Plan + +### Phase 1: Database Changes + +1. Create `content_authors` table +2. Add new columns to `perspectives` +3. Backfill: copy `author_user_id` to `proposer_user_id` and create `content_authors` records + +### Phase 2: API Updates + +1. Add new endpoints (`/api/content/*`) +2. Update existing perspective endpoints to use new permission model +3. Ensure backward compatibility with existing clients + +### Phase 3: Addie Tools + +1. Add `list_pending_content`, `approve_content`, `reject_content` +2. Update `create_perspective` with committee targeting +3. Add proactive pending notifications + +### Phase 4: UI + +1. Add "My Content" section to dashboard +2. Add pending review queue to committee manage pages +3. Update content creation flow + +--- + +## Success Criteria + +- [ ] Multiple authors can be credited on a single piece of content +- [ ] Committee leads control committee content (not individual authors) +- [ ] Non-leads can propose content that goes to pending review +- [ ] Addie proactively notifies leads/admins of pending items +- [ ] "My Content" view shows all content where user has a relationship +- [ ] Existing content continues to work (backward compatible) + +--- + +## Terminology + +- Use **"committee"** in user-facing text (covers working groups, councils, chapters) +- Keep `working_group_id` as column name (no DB rename needed) +- API uses `committee_slug` not `working_group_slug` for new endpoints From 0d82361b9c99b2e314eb018e5ee0d4eda84b8857 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Fri, 9 Jan 2026 13:58:23 -0500 Subject: [PATCH 11/77] feat: add Slack history backfill for Addie's search_slack tool (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Slack history backfill for Addie's search_slack tool Adds the ability to backfill historical Slack messages so Addie can search community discussions beyond the 5-day window of real-time indexing. Features: - Fetches messages from public channels (private opt-in) - Includes thread replies with rate limiting - Excludes sensitive channels (admin, billing) by default - Idempotent via upsert (safe to run multiple times) New API endpoints: - POST /api/admin/addie/backfill/slack - trigger backfill - GET /api/admin/addie/backfill/slack/status - check index stats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add channel filter to search_slack tool Enables Addie to filter Slack search results by channel name, which is needed for requests like "summarize Governance working group discussions". Changes: - Added channel parameter to search_slack tool schema - Updated tool description with guidance on channel filtering - Database query now supports optional channel filtering with partial match - Increased default/max limits to support summary use cases (10/25) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add get_channel_activity tool for channel summaries Adds a dedicated tool for getting recent activity from a Slack channel without requiring keyword search. This is the right tool for requests like "summarize the Governance working group discussions". Changes: - Added get_channel_activity tool with channel, days, and limit params - Added getChannelActivity database method that fetches by recency - Response includes message count, most active users, and formatted messages - Provides guidance for synthesizing results into summaries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/seven-squids-carry.md | 2 + .../src/addie/jobs/slack-history-backfill.ts | 410 ++++++++++++++++++ server/src/addie/mcp/knowledge-search.ts | 116 ++++- server/src/db/addie-db.ts | 55 ++- server/src/routes/addie-admin.ts | 136 ++++++ server/src/slack/client.ts | 105 +++++ 6 files changed, 811 insertions(+), 13 deletions(-) create mode 100644 .changeset/seven-squids-carry.md create mode 100644 server/src/addie/jobs/slack-history-backfill.ts 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/server/src/addie/jobs/slack-history-backfill.ts b/server/src/addie/jobs/slack-history-backfill.ts new file mode 100644 index 0000000000..15e9429548 --- /dev/null +++ b/server/src/addie/jobs/slack-history-backfill.ts @@ -0,0 +1,410 @@ +/** + * Slack History Backfill Job + * + * Indexes historical Slack messages for Addie's search_slack tool. + * This job fetches message history from channels and stores them + * in the addie_knowledge table for full-text search. + * + * Features: + * - Fetches both top-level messages and thread replies + * - Supports public and private channels + * - Excludes sensitive channels (admin, billing) by default + * - Idempotent - running multiple times won't create duplicates + */ + +import { logger } from '../../logger.js'; +import { AddieDatabase } from '../../db/addie-db.js'; +import { + getSlackChannels, + getFullChannelHistory, + getThreadReplies, + getSlackUser, + getChannelInfo, + type SlackHistoryMessage, +} from '../../slack/client.js'; + +const addieDb = new AddieDatabase(); + +/** + * Default channels to exclude from indexing (sensitive/admin channels) + * These contain billing info, admin discussions, etc. that shouldn't be searchable + */ +const DEFAULT_EXCLUDED_CHANNELS = [ + 'aao-admin', + 'aao-billing', + 'admin', + 'billing', + 'finance', + 'hr', + 'legal', + 'executive', + 'board', + 'confidential', +]; + +export interface BackfillResult { + channelsProcessed: number; + messagesIndexed: number; + threadRepliesIndexed: number; + messagesSkipped: number; + errors: number; + channels: Array<{ + id: string; + name: string; + isPrivate: boolean; + messagesIndexed: number; + threadRepliesIndexed: number; + messagesSkipped: number; + }>; +} + +export interface BackfillOptions { + /** Number of days of history to fetch (default: 90) */ + daysBack?: number; + /** Maximum messages per channel (default: 1000) */ + maxMessagesPerChannel?: number; + /** Specific channel IDs to backfill (default: all accessible channels) */ + channelIds?: string[]; + /** Include private channels (default: false) */ + includePrivateChannels?: boolean; + /** Channel names to exclude (default: admin/billing channels) */ + excludeChannelNames?: string[]; + /** Minimum message length to index (default: 20) */ + minMessageLength?: number; + /** Include thread replies (default: true) */ + includeThreadReplies?: boolean; + /** Progress callback */ + onProgress?: (status: { + channel: string; + messagesProcessed: number; + totalChannels: number; + currentChannel: number; + phase: 'messages' | 'threads'; + }) => void; +} + +/** + * Run the Slack history backfill job + * + * Fetches historical messages from channels and indexes them + * for Addie's search functionality. + */ +export async function runSlackHistoryBackfill(options: BackfillOptions = {}): Promise<BackfillResult> { + const { + daysBack = 90, + maxMessagesPerChannel = 1000, + channelIds, + includePrivateChannels = false, + excludeChannelNames = DEFAULT_EXCLUDED_CHANNELS, + minMessageLength = 20, + includeThreadReplies = true, + onProgress, + } = options; + + logger.info({ + daysBack, + maxMessagesPerChannel, + channelIds, + includePrivateChannels, + excludeChannelNames, + includeThreadReplies, + }, 'Starting Slack history backfill'); + + const result: BackfillResult = { + channelsProcessed: 0, + messagesIndexed: 0, + threadRepliesIndexed: 0, + messagesSkipped: 0, + errors: 0, + channels: [], + }; + + // Calculate oldest timestamp + const oldestDate = new Date(); + oldestDate.setDate(oldestDate.getDate() - daysBack); + const oldestTs = (oldestDate.getTime() / 1000).toString(); + + // Normalize excluded channel names for comparison + const excludedNamesLower = excludeChannelNames.map(n => n.toLowerCase()); + + // Get channels to process + let channels: Array<{ id: string; name: string; isPrivate: boolean }>; + + if (channelIds && channelIds.length > 0) { + // Use specific channels + channels = []; + for (const id of channelIds) { + const channelInfo = await getChannelInfo(id); + if (channelInfo) { + const name = channelInfo.name || 'unknown'; + // Check exclusion list even for specific channel IDs + if (excludedNamesLower.includes(name.toLowerCase())) { + logger.info({ channelId: id, name }, 'Skipping excluded channel'); + continue; + } + channels.push({ + id, + name, + isPrivate: channelInfo.is_private || false, + }); + } else { + logger.warn({ channelId: id }, 'Could not fetch channel info, skipping'); + } + } + } else { + // Get channels based on type preference + const channelTypes = includePrivateChannels + ? 'public_channel,private_channel' + : 'public_channel'; + + const allChannels = await getSlackChannels({ + types: channelTypes, + exclude_archived: true, + }); + + // Filter out excluded channels + channels = allChannels + .filter(c => !excludedNamesLower.includes((c.name || '').toLowerCase())) + .map(c => ({ + id: c.id, + name: c.name || 'unknown', + isPrivate: c.is_private || false, + })); + } + + logger.info({ + channelCount: channels.length, + publicCount: channels.filter(c => !c.isPrivate).length, + privateCount: channels.filter(c => c.isPrivate).length, + }, 'Backfilling channels'); + + // User cache to avoid repeated lookups + const userCache = new Map<string, { displayName: string } | null>(); + + async function getUserDisplayName(userId: string): Promise<string> { + if (userCache.has(userId)) { + return userCache.get(userId)?.displayName || 'unknown'; + } + + try { + const user = await getSlackUser(userId); + if (user) { + const displayName = user.profile?.display_name || user.profile?.real_name || user.name || 'unknown'; + userCache.set(userId, { displayName }); + return displayName; + } + } catch (error) { + logger.debug({ error, userId }, 'Failed to fetch user info'); + } + + userCache.set(userId, null); + return 'unknown'; + } + + async function indexMessage( + channelId: string, + channelName: string, + message: { user?: string; text?: string; ts: string; bot_id?: string; subtype?: string }, + channelResult: { messagesIndexed: number; threadRepliesIndexed: number; messagesSkipped: number }, + isThreadReply: boolean = false + ): Promise<boolean> { + // Skip bot messages, subtypes (edits, deletes, etc.), and messages without text + if (message.bot_id || message.subtype || !message.text || !message.user) { + channelResult.messagesSkipped++; + return false; + } + + // Skip short messages + if (message.text.length < minMessageLength) { + channelResult.messagesSkipped++; + return false; + } + + try { + const username = await getUserDisplayName(message.user); + + // Construct permalink using env var or default workspace + const workspaceUrl = process.env.SLACK_WORKSPACE_URL || 'https://agenticads.slack.com'; + const tsForLink = message.ts.replace('.', ''); + const permalink = `${workspaceUrl}/archives/${channelId}/p${tsForLink}`; + + await addieDb.indexSlackMessage({ + channel_id: channelId, + channel_name: channelName, + user_id: message.user, + username, + ts: message.ts, + text: message.text, + permalink, + }); + + if (isThreadReply) { + channelResult.threadRepliesIndexed++; + } else { + channelResult.messagesIndexed++; + } + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Unique constraint violations are expected for duplicates - log at debug + if (errorMessage.includes('duplicate') || errorMessage.includes('unique') || errorMessage.includes('violates unique constraint')) { + logger.debug({ channelId, ts: message.ts }, 'Skipping duplicate message'); + } else { + // Unexpected error - log at warning level + logger.warn({ error, channelId, ts: message.ts }, 'Unexpected error indexing message'); + } + + channelResult.messagesSkipped++; + return false; + } + } + + // Process each channel + for (let i = 0; i < channels.length; i++) { + const channel = channels[i]; + const channelResult = { + id: channel.id, + name: channel.name, + isPrivate: channel.isPrivate, + messagesIndexed: 0, + threadRepliesIndexed: 0, + messagesSkipped: 0, + }; + + try { + logger.info({ + channel: channel.name, + channelId: channel.id, + isPrivate: channel.isPrivate, + }, 'Fetching channel history'); + + // Fetch messages + const messages = await getFullChannelHistory(channel.id, { + oldest: oldestTs, + maxMessages: maxMessagesPerChannel, + onProgress: (count) => { + if (onProgress) { + onProgress({ + channel: channel.name, + messagesProcessed: count, + totalChannels: channels.length, + currentChannel: i + 1, + phase: 'messages', + }); + } + }, + }); + + logger.info({ channel: channel.name, messageCount: messages.length }, 'Processing messages'); + + // Track threads that need reply fetching + const threadsToFetch: string[] = []; + + // Index each message + for (const message of messages) { + await indexMessage(channel.id, channel.name, message, channelResult, false); + + // Track threads with replies for later fetching + if (includeThreadReplies && message.thread_ts === message.ts && 'reply_count' in message) { + const replyCount = (message as SlackHistoryMessage & { reply_count?: number }).reply_count; + if (replyCount && replyCount > 0) { + threadsToFetch.push(message.ts); + } + } + } + + // Fetch and index thread replies + if (includeThreadReplies && threadsToFetch.length > 0) { + logger.info({ + channel: channel.name, + threadCount: threadsToFetch.length, + }, 'Fetching thread replies'); + + for (let t = 0; t < threadsToFetch.length; t++) { + const threadTs = threadsToFetch[t]; + + if (onProgress) { + onProgress({ + channel: channel.name, + messagesProcessed: t + 1, + totalChannels: channels.length, + currentChannel: i + 1, + phase: 'threads', + }); + } + + try { + const replies = await getThreadReplies(channel.id, threadTs); + + // Skip the first message (it's the parent, already indexed) + for (const reply of replies.slice(1)) { + await indexMessage(channel.id, channel.name, reply, channelResult, true); + } + } catch (error) { + logger.debug({ error, channelId: channel.id, threadTs }, 'Failed to fetch thread replies'); + } + + // Rate limit between thread fetches to avoid hitting Slack API limits + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + result.channelsProcessed++; + result.messagesIndexed += channelResult.messagesIndexed; + result.threadRepliesIndexed += channelResult.threadRepliesIndexed; + result.messagesSkipped += channelResult.messagesSkipped; + result.channels.push(channelResult); + + logger.info({ + channel: channel.name, + isPrivate: channel.isPrivate, + indexed: channelResult.messagesIndexed, + threadReplies: channelResult.threadRepliesIndexed, + skipped: channelResult.messagesSkipped, + }, 'Channel backfill complete'); + + } catch (error) { + logger.error({ error, channel: channel.name, channelId: channel.id }, 'Failed to process channel'); + result.errors++; + result.channels.push({ + ...channelResult, + messagesIndexed: 0, + threadRepliesIndexed: 0, + messagesSkipped: 0, + }); + } + } + + logger.info({ + channelsProcessed: result.channelsProcessed, + messagesIndexed: result.messagesIndexed, + threadRepliesIndexed: result.threadRepliesIndexed, + messagesSkipped: result.messagesSkipped, + errors: result.errors, + }, 'Slack history backfill complete'); + + return result; +} + +/** + * Get current backfill status + * Returns stats about what's currently indexed + */ +export async function getBackfillStatus(): Promise<{ + totalMessages: number; + channelCounts: Array<{ channel: string; count: number }>; + oldestMessage: Date | null; + newestMessage: Date | null; +}> { + const count = await addieDb.getSlackMessageCount(); + + // Get channel breakdown - this requires a custom query + // For now, return basic stats + return { + totalMessages: count, + channelCounts: [], // Would need to add a method to AddieDatabase + oldestMessage: null, + newestMessage: null, + }; +} diff --git a/server/src/addie/mcp/knowledge-search.ts b/server/src/addie/mcp/knowledge-search.ts index 9a7608660b..9e4b30bba1 100644 --- a/server/src/addie/mcp/knowledge-search.ts +++ b/server/src/addie/mcp/knowledge-search.ts @@ -229,23 +229,51 @@ export const KNOWLEDGE_TOOLS: AddieTool[] = [ { name: 'search_slack', description: - 'Search Slack messages from public channels in the AAO workspace. Use this when you need community discussions, Q&A threads, or real-world implementation examples. Recent messages are searched instantly from local index; older messages may fall back to live API (slower). Cite the Slack permalink when using information from results.', - usage_hints: 'use for community Q&A, "what did someone say about X?", real-world discussions', + 'Search Slack messages from public channels in the AAO workspace. Use this when you need community discussions, Q&A threads, or real-world implementation examples. When asked about a specific channel or working group (e.g., "Governance working group"), use the channel parameter to filter results. When asked to summarize discussions, search for relevant keywords then synthesize the results. Cite the Slack permalink when using information from results.', + usage_hints: 'use for community Q&A, "what did someone say about X?", channel summaries, working group discussions', input_schema: { type: 'object', properties: { query: { type: 'string', - description: 'Search query - keywords or phrases to find in Slack messages', + description: 'Search query - keywords or phrases to find in Slack messages. Use broad terms when summarizing a channel (e.g., "governance" for governance discussions).', + }, + channel: { + type: 'string', + description: 'Optional channel name to filter results (e.g., "governance-wg", "general"). Partial matches work.', }, limit: { type: 'number', - description: 'Maximum number of results (default 5, max 10)', + description: 'Maximum number of results (default 10, max 25 for summaries)', }, }, required: ['query'], }, }, + { + name: 'get_channel_activity', + description: + 'Get recent messages from a specific Slack channel. Use this when asked to summarize channel activity, see what a working group has been discussing, or get an overview of conversations in a channel. Returns messages sorted by recency. After getting results, synthesize them into a summary for the user.', + usage_hints: 'use for "summarize the governance channel", "what has the X working group been discussing?", channel overviews', + input_schema: { + type: 'object', + properties: { + channel: { + type: 'string', + description: 'Channel name to get activity from (e.g., "governance-wg", "general"). Partial matches work.', + }, + days: { + type: 'number', + description: 'How many days back to look (default 30, max 90)', + }, + limit: { + type: 'number', + description: 'Maximum number of messages to return (default 25, max 50)', + }, + }, + required: ['channel'], + }, + }, { name: 'search_resources', description: @@ -610,12 +638,13 @@ ${excerpt}`; }); handlers.set('search_slack', async (input) => { - const query = input.query as string; - const limit = Math.min((input.limit as number) || 5, 10); + const searchQuery = input.query as string; + const channel = input.channel as string | undefined; + const limit = Math.min((input.limit as number) || 10, 25); try { - // First, try local database search (instant, ~100ms) - const localResults = await addieDb.searchSlackMessages(query, { limit }); + // Search local database with optional channel filter + const localResults = await addieDb.searchSlackMessages(searchQuery, { limit, channel }); if (localResults.length > 0) { const formatted = localResults @@ -634,17 +663,80 @@ ${excerpt}`; }) .join('\n\n'); - return `Found ${localResults.length} Slack messages (from local index):\n\n${formatted}\n\n**Remember to cite the Slack permalink when using this information.**`; + const channelNote = channel ? ` in channels matching "${channel}"` : ''; + return `Found ${localResults.length} Slack messages${channelNote}:\n\n${formatted}\n\n**Remember to cite the Slack permalink when using this information.**`; } - // No local results found - return `No Slack discussions found for: "${query}"\n\nTry search_docs for documentation or web_search for external sources.`; + // No local results found - provide helpful guidance + const channelNote = channel ? ` in channel "${channel}"` : ''; + return `No Slack discussions found for: "${searchQuery}"${channelNote}\n\nTry:\n- Broader search terms\n- Removing the channel filter\n- search_docs for documentation`; } catch (error) { - logger.error({ error, query }, 'Addie: Slack search failed'); + logger.error({ error, query: searchQuery, channel }, 'Addie: Slack search failed'); return `Slack search failed: ${error instanceof Error ? error.message : 'Unknown error'}`; } }); + handlers.set('get_channel_activity', async (input) => { + const channel = input.channel as string; + const days = input.days as number | undefined; + const limit = input.limit as number | undefined; + + try { + const messages = await addieDb.getChannelActivity(channel, { days, limit }); + + if (messages.length === 0) { + return `No recent activity found in channels matching "${channel}".\n\nThis could mean:\n- The channel name might be different (try partial matches like "govern" for "governance-wg")\n- No messages in the last ${days ?? 30} days\n- The channel may not be indexed yet`; + } + + // Group messages by user to help with "who's most active" analysis + const userCounts = new Map<string, number>(); + for (const msg of messages) { + userCounts.set(msg.username, (userCounts.get(msg.username) || 0) + 1); + } + const topUsers = [...userCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, count]) => `@${name} (${count})`) + .join(', '); + + const formatted = messages + .map((msg, i) => { + const cleanText = msg.text + .replace(/\s+/g, ' ') + .trim() + .substring(0, 400); + const truncated = cleanText.length < msg.text.length ? '...' : ''; + const date = new Date(msg.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + return `### ${i + 1}. @${msg.username} (${date}) +"${cleanText}${truncated}" + +**Source:** ${msg.permalink}`; + }) + .join('\n\n'); + + const channelName = messages[0]?.channel_name || channel; + return `## Recent activity in #${channelName} + +**${messages.length} messages** from the last ${days ?? 30} days +**Most active:** ${topUsers} + +--- + +${formatted} + +--- + +**When summarizing:** Focus on key themes, decisions, and who contributed to each topic. Cite specific messages using their Slack permalinks.`; + } catch (error) { + logger.error({ error, channel }, 'Addie: get_channel_activity failed'); + return `Failed to get channel activity: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }); + handlers.set('search_resources', async (input) => { const query = input.query as string; const limit = Math.min((input.limit as number) || 5, 10); diff --git a/server/src/db/addie-db.ts b/server/src/db/addie-db.ts index e469ad8a7c..4fa5bf481f 100644 --- a/server/src/db/addie-db.ts +++ b/server/src/db/addie-db.ts @@ -616,8 +616,19 @@ export class AddieDatabase { */ async searchSlackMessages(searchQuery: string, options: { limit?: number; + channel?: string; } = {}): Promise<SlackSearchResult[]> { const limit = options.limit ?? 10; + const channel = options.channel; + + // Build query with optional channel filter + const channelFilter = channel + ? `AND LOWER(slack_channel_name) LIKE LOWER($3)` + : ''; + const params: (string | number)[] = [searchQuery, limit]; + if (channel) { + params.push(`%${channel}%`); + } const result = await query<SlackSearchResult>( `SELECT @@ -633,9 +644,10 @@ export class AddieDatabase { WHERE is_active = TRUE AND source_type = 'slack' AND search_vector @@ websearch_to_tsquery('english', $1) + ${channelFilter} ORDER BY rank DESC LIMIT $2`, - [searchQuery, limit] + params ); return result.rows; } @@ -650,6 +662,47 @@ export class AddieDatabase { return parseInt(result.rows[0]?.count ?? '0', 10); } + /** + * Get recent messages from a channel (no keyword search, just by recency) + */ + async getChannelActivity(channel: string, options: { + days?: number; + limit?: number; + } = {}): Promise<Array<{ + text: string; + channel_name: string; + username: string; + permalink: string; + created_at: Date; + }>> { + const days = Math.min(options.days ?? 30, 90); + const limit = Math.min(options.limit ?? 25, 50); + + const result = await query<{ + text: string; + channel_name: string; + username: string; + permalink: string; + created_at: Date; + }>( + `SELECT + content as text, + slack_channel_name as channel_name, + slack_username as username, + slack_permalink as permalink, + created_at + FROM addie_knowledge + WHERE is_active = TRUE + AND source_type = 'slack' + AND LOWER(slack_channel_name) LIKE LOWER($1) + AND created_at >= NOW() - INTERVAL '1 day' * $2 + ORDER BY created_at DESC + LIMIT $3`, + [`%${channel}%`, days, limit] + ); + return result.rows; + } + // ============== Curated Resource Indexing ============== /** diff --git a/server/src/routes/addie-admin.ts b/server/src/routes/addie-admin.ts index 152481e53e..faa56795a7 100644 --- a/server/src/routes/addie-admin.ts +++ b/server/src/routes/addie-admin.ts @@ -21,6 +21,7 @@ import Anthropic from "@anthropic-ai/sdk"; import { getAddieBoltApp } from "../addie/bolt-app.js"; import { AddieRouter, type RoutingContext } from "../addie/router.js"; import { sanitizeInput } from "../addie/security.js"; +import { runSlackHistoryBackfill } from "../addie/jobs/slack-history-backfill.js"; const logger = createLogger("addie-admin-routes"); const addieDb = new AddieDatabase(); @@ -2166,5 +2167,140 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be } }); + // ========================================================================= + // SLACK HISTORY BACKFILL API (mounted at /api/admin/addie/backfill) + // ========================================================================= + + // POST /api/admin/addie/backfill/slack - Trigger Slack history backfill + apiRouter.post("/backfill/slack", requireAuth, requireAdmin, async (req, res) => { + try { + const { + days_back, + max_messages_per_channel, + channel_ids, + include_private_channels, + exclude_channel_names, + include_thread_replies, + } = req.body; + + // Validate days_back + const daysBack = days_back ? parseInt(String(days_back), 10) : 90; + if (isNaN(daysBack) || daysBack < 1 || daysBack > 365) { + res.status(400).json({ error: "days_back must be between 1 and 365" }); + return; + } + + // Validate max_messages_per_channel + const maxMessagesPerChannel = max_messages_per_channel ? parseInt(String(max_messages_per_channel), 10) : 1000; + if (isNaN(maxMessagesPerChannel) || maxMessagesPerChannel < 1 || maxMessagesPerChannel > 10000) { + res.status(400).json({ error: "max_messages_per_channel must be between 1 and 10000" }); + return; + } + + // Validate channel_ids format if provided + if (channel_ids !== undefined) { + if (!Array.isArray(channel_ids)) { + res.status(400).json({ error: "channel_ids must be an array" }); + return; + } + for (const id of channel_ids) { + if (typeof id !== 'string' || !/^C[A-Z0-9]+$/i.test(id)) { + res.status(400).json({ error: `Invalid channel ID format: ${id}` }); + return; + } + } + } + + // Validate exclude_channel_names if provided + if (exclude_channel_names !== undefined) { + if (!Array.isArray(exclude_channel_names) || !exclude_channel_names.every(n => typeof n === 'string')) { + res.status(400).json({ error: "exclude_channel_names must be an array of strings" }); + return; + } + } + + logger.info({ + daysBack, + maxMessagesPerChannel, + channelIds: channel_ids, + includePrivateChannels: include_private_channels, + excludeChannelNames: exclude_channel_names, + includeThreadReplies: include_thread_replies, + }, "Starting Slack history backfill"); + + // Run backfill (this may take a while for large workspaces) + const result = await runSlackHistoryBackfill({ + daysBack, + maxMessagesPerChannel, + channelIds: channel_ids, + includePrivateChannels: include_private_channels ?? false, + excludeChannelNames: exclude_channel_names, + includeThreadReplies: include_thread_replies ?? true, + }); + + logger.info({ + channelsProcessed: result.channelsProcessed, + messagesIndexed: result.messagesIndexed, + threadRepliesIndexed: result.threadRepliesIndexed, + }, "Slack history backfill complete"); + + res.json({ + success: true, + result, + }); + } catch (error) { + logger.error({ err: error }, "Error running Slack history backfill"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unable to run backfill", + }); + } + }); + + // GET /api/admin/addie/backfill/slack/status - Get current Slack index status + apiRouter.get("/backfill/slack/status", requireAuth, requireAdmin, async (req, res) => { + try { + // Get count of indexed messages + const totalCount = await addieDb.getSlackMessageCount(); + + // Get channel breakdown + const channelResult = await query<{ slack_channel_name: string; count: string }>( + `SELECT slack_channel_name, COUNT(*)::text as count + FROM addie_knowledge + WHERE source_type = 'slack' AND is_active = TRUE + GROUP BY slack_channel_name + ORDER BY COUNT(*) DESC` + ); + + // Get date range + const dateResult = await query<{ oldest: string; newest: string }>( + `SELECT + MIN(created_at)::text as oldest, + MAX(created_at)::text as newest + FROM addie_knowledge + WHERE source_type = 'slack'` + ); + + res.json({ + success: true, + status: { + totalMessages: totalCount, + channels: channelResult.rows.map(r => ({ + name: r.slack_channel_name, + count: parseInt(r.count, 10), + })), + oldestIndexed: dateResult.rows[0]?.oldest || null, + newestIndexed: dateResult.rows[0]?.newest || null, + }, + }); + } catch (error) { + logger.error({ err: error }, "Error getting Slack index status"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unable to get status", + }); + } + }); + return { pageRouter, apiRouter }; } diff --git a/server/src/slack/client.ts b/server/src/slack/client.ts index 71ebe80483..e4faa766cb 100644 --- a/server/src/slack/client.ts +++ b/server/src/slack/client.ts @@ -722,3 +722,108 @@ export async function getUserChannels(userId: string): Promise<string[]> { logger.debug({ userId, channelCount: channelIds.length }, 'Fetched user channel memberships'); return channelIds; } + +/** + * Message from conversations.history + */ +export interface SlackHistoryMessage { + type: string; + user?: string; + bot_id?: string; + text?: string; + ts: string; + thread_ts?: string; + subtype?: string; + reply_count?: number; // Number of replies in thread (for parent messages) +} + +/** + * Get channel message history (conversations.history) + * Returns messages from a channel, paginated + * + * @param channelId - The channel ID to fetch history from + * @param options - Pagination and filtering options + * @returns Array of messages and pagination info + */ +export async function getChannelHistory( + channelId: string, + options: { + oldest?: string; // Unix timestamp - only messages after this time + latest?: string; // Unix timestamp - only messages before this time + limit?: number; // Max messages per request (default 100, max 1000) + cursor?: string; // Pagination cursor + } = {} +): Promise<{ messages: SlackHistoryMessage[]; hasMore: boolean; nextCursor?: string }> { + try { + const response = await slackRequest<{ + messages: SlackHistoryMessage[]; + has_more: boolean; + response_metadata?: { next_cursor?: string }; + }>('conversations.history', { + channel: channelId, + oldest: options.oldest, + latest: options.latest, + limit: options.limit ?? 100, + cursor: options.cursor, + }); + + return { + messages: response.messages ?? [], + hasMore: response.has_more ?? false, + nextCursor: response.response_metadata?.next_cursor, + }; + } catch (error) { + logger.error({ error, channelId }, 'Failed to get channel history'); + return { messages: [], hasMore: false }; + } +} + +/** + * Get all messages from a channel within a time range + * Handles pagination automatically with rate limiting + * + * @param channelId - The channel ID to fetch history from + * @param options - Time range and limit options + * @returns Array of all messages in the time range + */ +export async function getFullChannelHistory( + channelId: string, + options: { + oldest?: string; // Unix timestamp - only messages after this time + latest?: string; // Unix timestamp - only messages before this time + maxMessages?: number; // Stop after this many messages (default: no limit) + onProgress?: (count: number) => void; // Callback for progress updates + } = {} +): Promise<SlackHistoryMessage[]> { + const allMessages: SlackHistoryMessage[] = []; + let cursor: string | undefined; + const maxMessages = options.maxMessages ?? Infinity; + + do { + const result = await getChannelHistory(channelId, { + oldest: options.oldest, + latest: options.latest, + limit: 200, // Fetch in larger batches for efficiency + cursor, + }); + + allMessages.push(...result.messages); + + if (options.onProgress) { + options.onProgress(allMessages.length); + } + + if (allMessages.length >= maxMessages) { + break; + } + + cursor = result.nextCursor; + + if (cursor) { + await sleep(RATE_LIMIT_DELAY_MS); + } + } while (cursor); + + logger.debug({ channelId, messageCount: allMessages.length }, 'Fetched full channel history'); + return allMessages.slice(0, maxMessages); +} From cdcd70fba72cf0a2c60a04fc1f3f86e103790ca2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Fri, 9 Jan 2026 14:09:36 -0500 Subject: [PATCH 12/77] fix: reorder migration 151 to delete duplicates before update (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration was failing because updating Slack IDs to WorkOS IDs would violate the unique constraint when both records already exist. Fix: Delete duplicate Slack-based records first (where a WorkOS-based record already exists), then safely update the remaining records. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/fix-migration-151.md | 5 ++ .../151_consolidate_slack_workos_ids.sql | 53 ++++++++++--------- 2 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 .changeset/fix-migration-151.md 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/server/src/db/migrations/151_consolidate_slack_workos_ids.sql b/server/src/db/migrations/151_consolidate_slack_workos_ids.sql index ee7e5ce99c..482dc72432 100644 --- a/server/src/db/migrations/151_consolidate_slack_workos_ids.sql +++ b/server/src/db/migrations/151_consolidate_slack_workos_ids.sql @@ -1,50 +1,55 @@ --- Migration: 149_consolidate_slack_workos_ids.sql +-- Migration: 151_consolidate_slack_workos_ids.sql -- Consolidate duplicate membership and leader records where users have both Slack and WorkOS IDs -- -- Problem: Users who joined via Slack and later linked their WorkOS account have duplicate records. -- The same user appears with their Slack ID (e.g., U123) and their WorkOS ID (e.g., user_abc). -- The slack_user_mappings table links these together. -- --- Solution: Update all records to use the canonical WorkOS user ID, then remove duplicates. +-- Solution: Delete duplicates first (where both Slack and WorkOS records exist), then update remaining records. --- Step 1: Update working_group_memberships to use WorkOS user IDs where mappings exist --- This converts Slack IDs to their linked WorkOS IDs +-- Step 1: Delete the Slack-ID-based membership records where a WorkOS-ID-based record already exists +-- This handles the case where both U123 and user_abc records exist for the same user/group +DELETE FROM working_group_memberships wgm_slack +WHERE EXISTS ( + SELECT 1 FROM slack_user_mappings sm + WHERE wgm_slack.workos_user_id = sm.slack_user_id + AND sm.workos_user_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM working_group_memberships wgm_workos + WHERE wgm_workos.working_group_id = wgm_slack.working_group_id + AND wgm_workos.workos_user_id = sm.workos_user_id + ) +); + +-- Step 2: Now safely update remaining Slack IDs to WorkOS IDs (no duplicates possible) UPDATE working_group_memberships wgm SET workos_user_id = sm.workos_user_id FROM slack_user_mappings sm WHERE wgm.workos_user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL; --- Step 2: Remove duplicate memberships after consolidation --- Keep the oldest record (first joined) when duplicates exist --- Note: id is UUID (not sequential), so we use joined_at timestamp -DELETE FROM working_group_memberships wgm1 +-- Step 3: Delete the Slack-ID-based leader records where a WorkOS-ID-based record already exists +DELETE FROM working_group_leaders wgl_slack WHERE EXISTS ( - SELECT 1 FROM working_group_memberships wgm2 - WHERE wgm1.working_group_id = wgm2.working_group_id - AND wgm1.workos_user_id = wgm2.workos_user_id - AND wgm1.joined_at > wgm2.joined_at -- Keep the older record + SELECT 1 FROM slack_user_mappings sm + WHERE wgl_slack.user_id = sm.slack_user_id + AND sm.workos_user_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM working_group_leaders wgl_workos + WHERE wgl_workos.working_group_id = wgl_slack.working_group_id + AND wgl_workos.user_id = sm.workos_user_id + ) ); --- Step 3: Update working_group_leaders to use WorkOS user IDs where mappings exist +-- Step 4: Now safely update remaining Slack IDs to WorkOS IDs (no duplicates possible) UPDATE working_group_leaders wgl SET user_id = sm.workos_user_id FROM slack_user_mappings sm WHERE wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL; --- Step 4: Remove duplicate leaders after consolidation --- Keep the oldest record when duplicates exist -DELETE FROM working_group_leaders wgl1 -WHERE EXISTS ( - SELECT 1 FROM working_group_leaders wgl2 - WHERE wgl1.working_group_id = wgl2.working_group_id - AND wgl1.user_id = wgl2.user_id - AND wgl1.created_at > wgl2.created_at -- Keep the older record -); - -- Log the migration completion DO $$ BEGIN - RAISE NOTICE 'Migration 149: Consolidated Slack/WorkOS user IDs in working_group_memberships and working_group_leaders'; + RAISE NOTICE 'Migration 151: Consolidated Slack/WorkOS user IDs in working_group_memberships and working_group_leaders'; END $$; From bdc8270b5453045bda4ccec11de074d07151f0ca Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Fri, 9 Jan 2026 14:20:09 -0500 Subject: [PATCH 13/77] fix: resolve Slack/WorkOS leader IDs for chapter event management (#688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve Slack/WorkOS leader IDs at read time for permission checks Chapter leaders added via Slack ID before linking their WorkOS account couldn't manage events because the leader permission checks compared stored Slack IDs against login WorkOS IDs. Changes: - getLeaders/getLeadersBatch: Resolve canonical_user_id via slack_user_mappings - isLeader: Check both direct user_id match and Slack→WorkOS mapping - getCommitteesLedByUser: Support both ID types with DISTINCT count - content.ts: Fix is_leader subqueries to use mapping join - member-context.ts: Fix pending content leader query - outbound-db.ts: Fix capability counts for leaders 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: remove redundant resolveToCanonicalUserId calls Based on code review feedback: - Remove redundant resolveToCanonicalUserId() calls in isLeader() and getCommitteesLedByUser() since the JOIN handles ID resolution - Add consistent AND sm.workos_user_id IS NOT NULL to all JOINs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/david-event-mgmt-fix.md | 4 +++ server/src/addie/member-context.ts | 4 ++- server/src/db/outbound-db.ts | 8 ++++-- server/src/db/working-group-db.ts | 45 +++++++++++++++++++----------- server/src/routes/content.ts | 11 ++++++-- 5 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 .changeset/david-event-mgmt-fix.md 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/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index 49cd49a006..b07df117e0 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -34,11 +34,13 @@ async function getPendingContentForUser( const pool = getPool(); // Get committees user leads + // Join with slack_user_mappings to handle users who were added as leader via Slack ID const leaderResult = await pool.query( `SELECT wg.id, wg.name, wg.slug FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL JOIN working_groups wg ON wg.id = wgl.working_group_id - WHERE wgl.user_id = $1`, + WHERE wgl.user_id = $1 OR sm.workos_user_id = $1`, [workosUserId] ); const ledCommitteeIds = leaderResult.rows.map(c => c.id); diff --git a/server/src/db/outbound-db.ts b/server/src/db/outbound-db.ts index 695c0fec36..9997fba7d8 100644 --- a/server/src/db/outbound-db.ts +++ b/server/src/db/outbound-db.ts @@ -745,13 +745,17 @@ export async function getMemberCapabilities( WHERE wg.committee_type = 'working_group' AND ( EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) - OR EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) + OR EXISTS(SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1)) )) as wg_count, (SELECT COUNT(DISTINCT wg.id) FROM working_groups wg WHERE wg.committee_type = 'council' AND ( EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) - OR EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) + OR EXISTS(SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1)) )) as council_count`, [workosUserId] ), diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index be51e90fc1..c777a3fc67 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -592,11 +592,12 @@ export class WorkingGroupDatabase { // 3. organization_memberships (older sync table) // 4. Falls back to user_id if no name found // - // Note: canonical_user_id equals user_id since we store canonical IDs at write time + // canonical_user_id is resolved at read time via slack_user_mappings + // to handle cases where leaders were added via Slack ID before linking WorkOS const result = await query<WorkingGroupLeader>( `SELECT wgl.user_id, - wgl.user_id AS canonical_user_id, + COALESCE(sm.workos_user_id, wgl.user_id) AS canonical_user_id, COALESCE( NULLIF(wgm.user_name, ''), NULLIF(TRIM(CONCAT(u.first_name, ' ', u.last_name)), ''), @@ -608,13 +609,14 @@ export class WorkingGroupDatabase { COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, wgl.created_at FROM working_group_leaders wgl - LEFT JOIN working_group_memberships wgm ON wgl.user_id = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id - LEFT JOIN users u ON wgl.user_id = u.workos_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + LEFT JOIN working_group_memberships wgm ON COALESCE(sm.workos_user_id, wgl.user_id) = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id + LEFT JOIN users u ON COALESCE(sm.workos_user_id, wgl.user_id) = u.workos_user_id LEFT JOIN organizations user_org ON u.primary_organization_id = user_org.workos_organization_id LEFT JOIN LATERAL ( SELECT om.first_name, om.last_name, om.email, om.workos_organization_id FROM organization_memberships om - WHERE om.workos_user_id = wgl.user_id + WHERE om.workos_user_id = COALESCE(sm.workos_user_id, wgl.user_id) ORDER BY om.created_at DESC LIMIT 1 ) om ON true @@ -635,11 +637,13 @@ export class WorkingGroupDatabase { return new Map(); } + // canonical_user_id is resolved at read time via slack_user_mappings + // to handle cases where leaders were added via Slack ID before linking WorkOS const result = await query<WorkingGroupLeader & { working_group_id: string }>( `SELECT wgl.working_group_id, wgl.user_id, - wgl.user_id AS canonical_user_id, + COALESCE(sm.workos_user_id, wgl.user_id) AS canonical_user_id, COALESCE( NULLIF(wgm.user_name, ''), NULLIF(TRIM(CONCAT(u.first_name, ' ', u.last_name)), ''), @@ -651,13 +655,14 @@ export class WorkingGroupDatabase { COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, wgl.created_at FROM working_group_leaders wgl - LEFT JOIN working_group_memberships wgm ON wgl.user_id = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id - LEFT JOIN users u ON wgl.user_id = u.workos_user_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + LEFT JOIN working_group_memberships wgm ON COALESCE(sm.workos_user_id, wgl.user_id) = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id + LEFT JOIN users u ON COALESCE(sm.workos_user_id, wgl.user_id) = u.workos_user_id LEFT JOIN organizations user_org ON u.primary_organization_id = user_org.workos_organization_id LEFT JOIN LATERAL ( SELECT om.first_name, om.last_name, om.email, om.workos_organization_id FROM organization_memberships om - WHERE om.workos_user_id = wgl.user_id + WHERE om.workos_user_id = COALESCE(sm.workos_user_id, wgl.user_id) ORDER BY om.created_at DESC LIMIT 1 ) om ON true @@ -749,14 +754,19 @@ export class WorkingGroupDatabase { /** * Check if a user is a leader of a working group + * Handles both WorkOS and Slack user IDs by checking both directions of the mapping */ async isLeader(workingGroupId: string, userId: string): Promise<boolean> { - const canonicalUserId = await this.resolveToCanonicalUserId(userId); + // Check if: + // 1. The leader record has the user ID directly, OR + // 2. The leader record has a Slack ID that maps to this WorkOS user ID const result = await query( - `SELECT 1 FROM working_group_leaders - WHERE working_group_id = $1 AND user_id = $2 + `SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + WHERE wgl.working_group_id = $1 + AND (wgl.user_id = $2 OR sm.workos_user_id = $2) LIMIT 1`, - [workingGroupId, canonicalUserId] + [workingGroupId, userId] ); return result.rows.length > 0; } @@ -764,19 +774,20 @@ export class WorkingGroupDatabase { /** * Get all committees led by a user * Returns committees of all types where the user is a leader + * Handles both WorkOS and Slack user IDs by checking both directions of the mapping */ async getCommitteesLedByUser(userId: string): Promise<WorkingGroupWithMemberCount[]> { - const canonicalUserId = await this.resolveToCanonicalUserId(userId); const result = await query<WorkingGroupWithMemberCount>( - `SELECT wg.*, COUNT(wgm.id)::int AS member_count + `SELECT wg.*, COUNT(DISTINCT wgm.id)::int AS member_count FROM working_groups wg INNER JOIN working_group_leaders wgl ON wg.id = wgl.working_group_id + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL LEFT JOIN working_group_memberships wgm ON wg.id = wgm.working_group_id AND wgm.status = 'active' - WHERE wgl.user_id = $1 + WHERE (wgl.user_id = $1 OR sm.workos_user_id = $1) AND wg.status = 'active' GROUP BY wg.id ORDER BY wg.display_order, wg.name`, - [canonicalUserId] + [userId] ); const groups = result.rows; diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts index 79ba41bca4..598bd4c909 100644 --- a/server/src/routes/content.ts +++ b/server/src/routes/content.ts @@ -104,9 +104,14 @@ export function createContentRouter(): Router { ); // Get committees user is a member of (non-public ones) + // Join with slack_user_mappings to handle users who were added as leader via Slack ID const memberResult = await pool.query( `SELECT wg.id, wg.slug, wg.name, wg.description, - EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) as is_leader + EXISTS( + SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + WHERE wgl.working_group_id = wg.id AND (wgl.user_id = $1 OR sm.workos_user_id = $1) + ) as is_leader FROM working_group_memberships wgm JOIN working_groups wg ON wg.id = wgm.working_group_id WHERE wgm.workos_user_id = $1 @@ -320,11 +325,13 @@ export function createContentRouter(): Router { const pool = getPool(); // Get committees user leads + // Join with slack_user_mappings to handle users who were added as leader via Slack ID const leaderResult = await pool.query( `SELECT wg.id, wg.name, wg.slug FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL JOIN working_groups wg ON wg.id = wgl.working_group_id - WHERE wgl.user_id = $1`, + WHERE wgl.user_id = $1 OR sm.workos_user_id = $1`, [user.id] ); const ledCommittees = leaderResult.rows; From a703c97285376bf40b5a5e17c180199acc68089f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 06:57:24 -0500 Subject: [PATCH 14/77] fix: DM responses appearing in separate threads for AI Assistant apps (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues were causing DM responses to appear incorrectly: 1. For Slack AI/Assistant apps, every DM is treated as a thread. The code was not passing thread_ts when responding, causing responses to appear as separate notifications/threads instead of in the same conversation. 2. The Assistant framework receives message.im events through a separate pipeline from global middleware, causing both handleDirectMessage and handleUserMessage to fire for the same event, resulting in duplicate responses. Changes: - Add thread_ts: event.ts to chat.postMessage in handleDirectMessage - Add guard in handleUserMessage to skip DMs without thread_ts (handled by handleDirectMessage via middleware) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/bumpy-cycles-hear.md | 2 ++ server/src/addie/bolt-app.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .changeset/bumpy-cycles-hear.md 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/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index f9222ec0ef..93390c713e 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -743,6 +743,17 @@ async function handleUserMessage({ return; } + // Skip DMs without thread_ts - these are handled by handleDirectMessage via middleware. + // The Assistant framework receives message.im events through a separate pipeline from + // the global middleware chain, so both handlers can fire for the same event. + // First DMs (without thread_ts) are handled by handleDirectMessage; skip them here. + const channelType = 'channel_type' in event ? event.channel_type : undefined; + const hasRealThreadTs = 'thread_ts' in event && event.thread_ts !== undefined; + if (channelType === 'im' && !hasRealThreadTs) { + logger.debug({ channelType, hasThreadTs: hasRealThreadTs }, 'Addie Bolt: Skipping DM without thread_ts in Assistant handler (handled by middleware)'); + return; + } + const startTime = Date.now(); const channelId = event.channel; const threadService = getThreadService(); @@ -1767,12 +1778,15 @@ async function handleDirectMessage( // Validate output const outputValidation = validateOutput(response.text); - // Send response in the DM channel (no threading for DMs - just back-and-forth messages) + // Send response in the DM channel + // For AI Assistant apps, Slack treats every DM as a thread. We must use thread_ts + // to respond in the same thread as the user's message, otherwise our response + // appears as a separate notification in a different thread. try { await boltApp.client.chat.postMessage({ channel: channelId, text: outputValidation.sanitized, - // Don't use thread_ts for DMs - threading feels awkward in direct messages + thread_ts: event.ts, }); } catch (error) { logger.error({ error }, 'Addie Bolt: Failed to send DM response'); From 1eb8271c9b75725db5b6779c2f8666765cf91813 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 07:11:53 -0500 Subject: [PATCH 15/77] fix: handle RSS guids with missing text content (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some RSS feeds (e.g., Marketing Week) return guid elements with only XML attributes but no text content, resulting in objects like `{"$":{"isPermaLink":"false"}}` instead of strings. This caused "Cannot convert object to primitive value" errors. - Update FeedItem type to reflect optional `_` (text) and `$` (attrs) - Extract guid text content properly, falling back to link URL - Remove per-feed debug logs to reduce log volume - Only log RSS processing summary when there are new articles or errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/eighty-sloths-feel.md | 2 ++ server/src/addie/services/feed-fetcher.ts | 38 +++++++++++------------ server/src/db/industry-feeds-db.ts | 5 +-- 3 files changed, 22 insertions(+), 23 deletions(-) create mode 100644 .changeset/eighty-sloths-feel.md 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/server/src/addie/services/feed-fetcher.ts b/server/src/addie/services/feed-fetcher.ts index 709b02a6ed..f0a1811dc4 100644 --- a/server/src/addie/services/feed-fetcher.ts +++ b/server/src/addie/services/feed-fetcher.ts @@ -68,7 +68,7 @@ function validateRssContent(content: string, contentType: string): { valid: bool } interface FeedItem { - guid?: string | { _: string }; + guid?: string | { _?: string; $?: Record<string, string> }; link?: string; title?: string; pubDate?: string; @@ -98,12 +98,9 @@ function isRssFetchable(feedUrl: string | null): feedUrl is string { async function fetchFeed(feed: IndustryFeed): Promise<RssArticleInput[]> { // Skip email-only feeds (those with non-HTTP URLs like email://) if (!isRssFetchable(feed.feed_url)) { - logger.debug({ feedId: feed.id, name: feed.name, url: feed.feed_url }, 'Skipping non-HTTP feed'); return []; } - logger.debug({ feedId: feed.id, name: feed.name, url: feed.feed_url }, 'Fetching RSS feed'); - // Pre-fetch content to validate it's actually RSS/XML before parsing // This prevents cryptic XML parsing errors when sites return HTML const response = await fetch(feed.feed_url, { @@ -131,7 +128,6 @@ async function fetchFeed(feed: IndustryFeed): Promise<RssArticleInput[]> { const parsed = await parser.parseString(content); if (!parsed.items || parsed.items.length === 0) { - logger.warn({ feedId: feed.id }, 'Feed returned no items'); return []; } @@ -144,11 +140,18 @@ async function fetchFeed(feed: IndustryFeed): Promise<RssArticleInput[]> { } // Generate a stable GUID - // Handle RSS guids that may be objects with _ property (from XML attributes) + // Handle RSS guids that may be objects with _ property (text content) from XML parsing + // Some feeds have guid with only attributes ($) but no text content (_) const rawGuid = item.guid; - const guid = typeof rawGuid === 'object' && rawGuid !== null && '_' in rawGuid - ? rawGuid._ - : (rawGuid || item.link); + let guid: string; + if (typeof rawGuid === 'string') { + guid = rawGuid; + } else if (typeof rawGuid === 'object' && rawGuid !== null && '_' in rawGuid && rawGuid._) { + guid = rawGuid._; + } else { + // Fall back to link if guid is missing or empty object + guid = item.link; + } // Parse publication date let publishedAt: Date | undefined; @@ -183,7 +186,6 @@ async function fetchFeed(feed: IndustryFeed): Promise<RssArticleInput[]> { }); } - logger.debug({ feedId: feed.id, articlesFound: articles.length }, 'Parsed RSS feed'); return articles; } @@ -198,12 +200,9 @@ export async function processFeedsToFetch(): Promise<{ const feeds = await getFeedsToFetch(); if (feeds.length === 0) { - logger.debug('No feeds need fetching'); return { feedsProcessed: 0, newPerspectives: 0, errors: 0 }; } - logger.debug({ feedCount: feeds.length }, 'Processing RSS feeds'); - let totalNewPerspectives = 0; let errorCount = 0; @@ -212,10 +211,8 @@ export async function processFeedsToFetch(): Promise<{ const articles = await fetchFeed(feed); if (articles.length > 0) { - // Create perspectives from RSS articles const created = await createRssPerspectivesBatch(articles); totalNewPerspectives += created; - logger.debug({ feedId: feed.id, created }, 'Created perspectives from RSS'); } await updateFeedStatus(feed.id, true); @@ -230,10 +227,13 @@ export async function processFeedsToFetch(): Promise<{ await new Promise(resolve => setTimeout(resolve, 500)); } - logger.info( - { feedsProcessed: feeds.length, newPerspectives: totalNewPerspectives, errors: errorCount }, - 'Completed RSS feed processing' - ); + // Only log when there's something notable + if (totalNewPerspectives > 0 || errorCount > 0) { + logger.info( + { feedsProcessed: feeds.length, newPerspectives: totalNewPerspectives, errors: errorCount }, + 'RSS feed processing complete' + ); + } return { feedsProcessed: feeds.length, diff --git a/server/src/db/industry-feeds-db.ts b/server/src/db/industry-feeds-db.ts index c22f15fc05..220003f71f 100644 --- a/server/src/db/industry-feeds-db.ts +++ b/server/src/db/industry-feeds-db.ts @@ -243,10 +243,7 @@ function generateSlug(title: string, guid: string): string { .replace(/^-|-$/g, '') .substring(0, 80); - // Ensure guid is a string (defensive against RSS parser quirks) - if (typeof guid !== 'string') { - logger.warn({ guid, title }, 'generateSlug received non-string guid'); - } + // Defensive: ensure guid is a string even if caller passes unexpected type const guidStr = String(guid || ''); // Add a hash of the guid to ensure uniqueness From 1a67f548ed37cafe87ca4b6ef78c1fe295e36514 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 07:12:05 -0500 Subject: [PATCH 16/77] fix: domain health linking and add domain health to account detail (#690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix "link user" button on domain health page - SQL now excludes users already in the target org - Add "Link All" button to batch-link all misaligned users to their corporate accounts - Fix 409 error for unverified orgs by filtering out domains already claimed by other organizations - Add "Similar Organization Names" section to identify potential duplicate orgs for merging - Restore "Unlinked Corporate Domains" section but filter to only show domains where users are in actual corporate orgs (not just personal workspaces) - Add Domain Health section to account detail page showing: - Users with this org's domain who aren't in this org - Potential duplicate organizations with similar names - Unverified domain warnings - Security fixes: Replace inline onclick handlers with event delegation and add input validation for user/org IDs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/hungry-bars-jump.md | 2 + server/public/admin-account-detail.html | 231 ++++++++++++++++++++ server/public/admin-domain-health.html | 279 ++++++++++++++++++++---- server/src/routes/admin/accounts.ts | 99 +++++++++ server/src/routes/admin/domains.ts | 72 +++++- 5 files changed, 635 insertions(+), 48 deletions(-) create mode 100644 .changeset/hungry-bars-jump.md 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/server/public/admin-account-detail.html b/server/public/admin-account-detail.html index ff3428641b..d11e36998a 100644 --- a/server/public/admin-account-detail.html +++ b/server/public/admin-account-detail.html @@ -825,6 +825,19 @@ <h2 class="collapsible collapsed" onclick="toggleSection(this)" style="margin-bo </div> </div> </div> + + <!-- Domain Health (only shown if there are issues) --> + <div class="card" id="domainHealthCard" style="display: none;"> + <h2 class="collapsible" onclick="toggleSection(this)" style="margin-bottom: 0;"> + Domain Health + <span id="domainHealthBadge" class="status-badge" style="margin-left: var(--space-2); font-size: var(--text-xs);"></span> + </h2> + <div class="collapsible-content" id="domainHealthContent" style="margin-top: var(--space-4);"> + <div id="domainHealthList" class="empty-state"> + No issues found + </div> + </div> + </div> </div> <!-- Right Column - Activity Timeline --> @@ -1376,6 +1389,9 @@ <h2 id="userContextModalTitle">Member Context</h2> document.getElementById('domainsList').innerHTML = domainsHtml; } + // Domain Health + renderDomainHealth(); + // Activity timeline renderActivities(); @@ -1409,6 +1425,221 @@ <h2 id="userContextModalTitle">Member Context</h2> watchBtn.className = `watch-btn ${isWatching ? 'watching' : ''}`; } + function renderDomainHealth() { + const health = accountData.domain_health; + const card = document.getElementById('domainHealthCard'); + const badge = document.getElementById('domainHealthBadge'); + const list = document.getElementById('domainHealthList'); + + if (!health) { + card.style.display = 'none'; + return; + } + + const { misaligned_users, similar_orgs, has_unverified_domains } = health; + const issueCount = (misaligned_users?.length || 0) + (similar_orgs?.length || 0) + (has_unverified_domains ? 1 : 0); + + if (issueCount === 0) { + card.style.display = 'none'; + return; + } + + card.style.display = 'block'; + badge.textContent = `${issueCount} issue${issueCount !== 1 ? 's' : ''}`; + badge.style.background = 'var(--color-warning-light)'; + badge.style.color = 'var(--color-warning-dark)'; + + let html = ''; + + // Unverified domains warning + if (has_unverified_domains) { + html += ` + <div style="padding: var(--space-3); background: var(--color-warning-light); border-radius: var(--radius-sm); margin-bottom: var(--space-3);"> + <strong style="color: var(--color-warning-dark);">Unverified Domains</strong> + <p style="margin: var(--space-1) 0 0; font-size: var(--text-sm); color: var(--color-text-secondary);"> + One or more domains are not verified. Verify domain ownership to enable automatic user matching. + </p> + </div> + `; + } + + // Misaligned users (people with this org's domain who aren't in this org) + if (misaligned_users?.length > 0) { + const inPersonal = misaligned_users.filter(u => u.in_personal_workspace); + const inOtherOrgs = misaligned_users.filter(u => !u.in_personal_workspace); + + html += ` + <div style="margin-bottom: var(--space-4);"> + <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--space-2); color: var(--color-text-heading);"> + Users With This Domain Not In This Org + <span style="color: var(--color-text-muted); font-weight: normal;">(${misaligned_users.length})</span> + </h3> + `; + + if (inPersonal.length > 0) { + html += ` + <p style="font-size: var(--text-sm); color: var(--color-text-secondary); margin-bottom: var(--space-2);"> + <strong>${inPersonal.length}</strong> in personal workspaces (can be added): + </p> + <ul style="list-style: none; padding: 0; margin: 0 0 var(--space-3) 0;"> + ${inPersonal.slice(0, 5).map(u => ` + <li style="display: flex; justify-content: space-between; align-items: center; padding: var(--space-2) 0; border-bottom: var(--border-1) solid var(--color-gray-100);"> + <span> + <span style="font-weight: 500;">${escapeHtml(u.first_name || '')} ${escapeHtml(u.last_name || '')}</span> + <span style="color: var(--color-text-muted); font-size: var(--text-sm);"> (${escapeHtml(u.email)})</span> + </span> + <button class="btn btn-sm btn-primary add-user-btn" data-user-id="${escapeHtml(u.user_id)}" data-user-email="${escapeHtml(u.email)}">Add</button> + </li> + `).join('')} + ${inPersonal.length > 5 ? `<li style="padding: var(--space-2) 0; color: var(--color-text-muted); font-size: var(--text-sm);">...and ${inPersonal.length - 5} more</li>` : ''} + </ul> + ${inPersonal.length > 1 ? ` + <button class="btn btn-sm btn-secondary add-all-users-btn" data-user-ids="${escapeHtml(JSON.stringify(inPersonal.map(u => u.user_id)))}"> + Add All ${inPersonal.length} Users + </button> + ` : ''} + `; + } + + if (inOtherOrgs.length > 0) { + html += ` + <p style="font-size: var(--text-sm); color: var(--color-text-secondary); margin: var(--space-3) 0 var(--space-2);"> + <strong>${inOtherOrgs.length}</strong> in other corporate orgs: + </p> + <ul style="list-style: none; padding: 0; margin: 0;"> + ${inOtherOrgs.slice(0, 5).map(u => ` + <li style="padding: var(--space-2) 0; border-bottom: var(--border-1) solid var(--color-gray-100);"> + <span style="font-weight: 500;">${escapeHtml(u.first_name || '')} ${escapeHtml(u.last_name || '')}</span> + <span style="color: var(--color-text-muted); font-size: var(--text-sm);"> (${escapeHtml(u.email)})</span> + <br><span style="font-size: var(--text-xs); color: var(--color-text-muted);">Currently in: <a href="/admin/accounts/${encodeURIComponent(u.current_org_id)}">${escapeHtml(u.current_org_name)}</a></span> + </li> + `).join('')} + ${inOtherOrgs.length > 5 ? `<li style="padding: var(--space-2) 0; color: var(--color-text-muted); font-size: var(--text-sm);">...and ${inOtherOrgs.length - 5} more</li>` : ''} + </ul> + `; + } + + html += '</div>'; + } + + // Similar organizations + if (similar_orgs?.length > 0) { + html += ` + <div> + <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--space-2); color: var(--color-text-heading);"> + Similar Organizations + <span style="color: var(--color-text-muted); font-weight: normal;">(${similar_orgs.length} potential duplicates)</span> + </h3> + <ul style="list-style: none; padding: 0; margin: 0;"> + ${similar_orgs.map(o => ` + <li style="display: flex; justify-content: space-between; align-items: center; padding: var(--space-2) 0; border-bottom: var(--border-1) solid var(--color-gray-100);"> + <span> + <a href="/admin/accounts/${encodeURIComponent(o.org_id)}" style="font-weight: 500;">${escapeHtml(o.name)}</a> + <span class="status-badge ${o.subscription_status === 'active' ? 'status-active' : 'status-prospect'}" style="margin-left: var(--space-2); font-size: var(--text-xs);"> + ${escapeHtml(o.subscription_status || 'prospect')} + </span> + </span> + </li> + `).join('')} + </ul> + <p style="font-size: var(--text-xs); color: var(--color-text-muted); margin-top: var(--space-2);"> + <a href="/admin/domain-health">View full domain health dashboard</a> to merge organizations. + </p> + </div> + `; + } + + list.innerHTML = html; + + // Set up event delegation for domain health buttons + list.querySelectorAll('.add-user-btn').forEach(btn => { + btn.addEventListener('click', () => { + const userId = btn.dataset.userId; + const userEmail = btn.dataset.userEmail; + addUserToOrg(userId, userEmail); + }); + }); + + list.querySelectorAll('.add-all-users-btn').forEach(btn => { + btn.addEventListener('click', () => { + const userIds = JSON.parse(btn.dataset.userIds); + addAllUsersToOrg(userIds); + }); + }); + } + + // Validate ID format (WorkOS IDs are alphanumeric with underscores) + function isValidId(id) { + return typeof id === 'string' && id.length > 0 && /^[a-zA-Z0-9_-]+$/.test(id); + } + + async function addUserToOrg(userId, userEmail) { + if (!isValidId(userId)) { + alert('Invalid user ID format'); + return; + } + if (!confirm(`Add ${userEmail} to ${accountData.name}?`)) return; + + try { + const response = await fetch(`/api/admin/organizations/${encodeURIComponent(accountId)}/add-users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_ids: [userId] }) + }); + + 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'); + } + + // Reload to show updated data + loadAccount(); + } catch (error) { + alert(`Failed to add user: ${error.message}`); + } + } + + async function addAllUsersToOrg(userIds) { + if (!Array.isArray(userIds) || userIds.length === 0) { + alert('No users to add'); + return; + } + if (!userIds.every(isValidId)) { + alert('Invalid user ID format detected'); + return; + } + if (!confirm(`Add ${userIds.length} users to ${accountData.name}?`)) return; + + try { + const response = await fetch(`/api/admin/organizations/${encodeURIComponent(accountId)}/add-users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_ids: 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(); + alert(`Added ${result.added_count || userIds.length} users to ${accountData.name}`); + + // Reload to show updated data + loadAccount(); + } catch (error) { + alert(`Failed to add users: ${error.message}`); + } + } + function renderNextSteps() { const steps = accountData.next_steps || []; if (steps.length === 0) { diff --git a/server/public/admin-domain-health.html b/server/public/admin-domain-health.html index 6e89af7ddf..c35d5c7d3f 100644 --- a/server/public/admin-domain-health.html +++ b/server/public/admin-domain-health.html @@ -262,7 +262,7 @@ <h1>Domain Health</h1> Unlinked Corporate Domains <span class="section-badge" id="orphanBadge">0</span> </div> - <p class="section-description">Corporate email domains not linked to any organization. Users may already be members of existing orgs - link the domain to consolidate.</p> + <p class="section-description">Corporate email domains not linked to any organization. Link these domains to consolidate users.</p> </div> </div> <div id="orphanDomainsContent"></div> @@ -278,6 +278,7 @@ <h1>Domain Health</h1> </div> <p class="section-description">Users in personal workspaces whose company already has an organization. Consider inviting them to join.</p> </div> + <button class="btn btn-primary" id="linkAllMisalignedBtn" style="display: none;">Link All</button> </div> <div id="misalignedUsersContent"></div> </div> @@ -323,6 +324,20 @@ <h1>Domain Health</h1> </div> <div id="relatedDomainsContent"></div> </div> + + <!-- Similar Names Section --> + <div class="card" id="similarNamesSection"> + <div class="section-header"> + <div> + <div class="section-title"> + Similar Organization Names + <span class="section-badge" id="similarNamesBadge">0</span> + </div> + <p class="section-description">Organizations with similar names that may be duplicates. Review and merge if they represent the same company.</p> + </div> + </div> + <div id="similarNamesContent"></div> + </div> </div> </div> @@ -381,12 +396,13 @@ <h1>Domain Health</h1> 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 +710,112 @@ <h1>Domain Health</h1> } } + 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 +847,21 @@ <h1>Domain Health</h1> 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; + }); - if (orphan_domains.length === 0) { + badge.textContent = domainsWithCorpOrgs.length; + badge.className = domainsWithCorpOrgs.length === 0 ? 'section-badge success' : 'section-badge'; + + if (domainsWithCorpOrgs.length === 0) { content.innerHTML = ` <div class="empty-state"> <div class="empty-state-icon">✓</div> - <p>No unlinked domains found. All corporate domains are properly linked to organizations.</p> + <p>No unlinked corporate domains found. All corporate domains are properly linked to organizations.</p> </div> `; return; @@ -754,8 +878,8 @@ <h1>Domain Health</h1> </tr> </thead> <tbody> - ${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 +891,6 @@ <h1>Domain Health</h1> 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 +898,30 @@ <h1>Domain Health</h1> <td><span class="domain-name">${escapeHtml(d.domain)}</span></td> <td><span class="user-count">${d.user_count}</span></td> <td> - ${hasExistingOrgs ? ` - <ul class="org-list"> - ${nonPersonalOrgs.map(o => ` - <li> - <a href="/admin/organizations/${o.org_id}" class="org-link">${escapeHtml(o.name)}</a> - <span class="user-count">${o.user_count} users</span> - </li> - `).join('')} - </ul> - ${personalOrgs.length > 0 ? `<div style="margin-top: var(--space-2); font-size: var(--text-xs); color: var(--color-text-muted);">+ ${personalOrgs.length} personal workspace${personalOrgs.length > 1 ? 's' : ''}</div>` : ''} - ` : ` - <span style="color: var(--color-text-muted);"> - ${personalOrgs.length > 0 ? `${personalOrgs.length} personal workspace${personalOrgs.length > 1 ? 's' : ''} only` : 'No existing orgs'} - </span> - `} + <ul class="org-list"> + ${nonPersonalOrgs.map(o => ` + <li> + <a href="/admin/accounts/${o.org_id}" class="org-link">${escapeHtml(o.name)}</a> + <span class="user-count">${o.user_count} users</span> + </li> + `).join('')} + </ul> </td> <td> <div style="display: flex; flex-direction: column; gap: var(--space-2);"> - ${hasExistingOrgs ? ` - ${nonPersonalOrgs.length === 1 ? ` - <button class="btn btn-primary btn-sm link-domain-btn" data-org-id="${escapeHtml(nonPersonalOrgs[0].org_id)}" data-org-name="${escapeHtml(nonPersonalOrgs[0].name)}" data-domain="${escapeHtml(d.domain)}">Link to ${escapeHtml(nonPersonalOrgs[0].name)}</button> - ` : ` - <select class="btn btn-sm link-domain-select" data-domain="${escapeHtml(d.domain)}" style="padding: var(--space-1) var(--space-2); cursor: pointer;"> - <option value="">Link to org...</option> - ${nonPersonalOrgs.map(o => ` - <option value="${escapeHtml(o.org_id)}" data-org-name="${escapeHtml(o.name)}">${escapeHtml(o.name)}</option> - `).join('')} - </select> - `} - ${hasMultipleOrgs ? ` - <button class="btn btn-secondary btn-sm merge-orgs-btn" data-org-ids="${nonPersonalOrgs.map(o => encodeURIComponent(o.org_id)).join(',')}" data-domain="${encodeURIComponent(d.domain)}">Merge ${nonPersonalOrgs.length} orgs</button> - ` : ''} + ${nonPersonalOrgs.length === 1 ? ` + <button class="btn btn-primary btn-sm link-domain-btn" data-org-id="${escapeHtml(nonPersonalOrgs[0].org_id)}" data-org-name="${escapeHtml(nonPersonalOrgs[0].name)}" data-domain="${escapeHtml(d.domain)}">Link to ${escapeHtml(nonPersonalOrgs[0].name)}</button> ` : ` - <button class="btn btn-primary btn-sm create-prospect-btn" data-domain="${escapeHtml(d.domain)}">Create Prospect</button> + <select class="btn btn-sm link-domain-select" data-domain="${escapeHtml(d.domain)}" style="padding: var(--space-1) var(--space-2); cursor: pointer;"> + <option value="">Link to org...</option> + ${nonPersonalOrgs.map(o => ` + <option value="${escapeHtml(o.org_id)}" data-org-name="${escapeHtml(o.name)}">${escapeHtml(o.name)}</option> + `).join('')} + </select> `} - <button class="btn btn-secondary btn-sm mark-personal-btn" data-domain="${escapeHtml(d.domain)}" title="Mark this domain as a personal email domain (not corporate)">Mark as Personal</button> + ${hasMultipleOrgs ? ` + <button class="btn btn-secondary btn-sm merge-orgs-btn" data-org-ids="${nonPersonalOrgs.map(o => encodeURIComponent(o.org_id)).join(',')}" data-domain="${encodeURIComponent(d.domain)}">Merge ${nonPersonalOrgs.length} orgs</button> + ` : ''} </div> </td> </tr> @@ -825,11 +935,25 @@ <h1>Domain Health</h1> 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 = ` <div class="empty-state"> @@ -934,8 +1058,11 @@ <h1>Domain Health</h1> </thead> <tbody> ${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())); + // Find the first corporate domain (not a personal email domain AND not claimed by another org) + const claimedByOthers = new Set((o.claimed_by_others || []).map(d => d.toLowerCase())); + const corporateDomains = (o.user_domains || []).filter(d => + d && !personalDomains.includes(d.toLowerCase()) && !claimedByOthers.has(d.toLowerCase()) + ); const suggestedDomain = corporateDomains[0] || null; return ` @@ -1093,6 +1220,64 @@ <h1>Domain Health</h1> `; } + 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 = ` + <div class="empty-state"> + <div class="empty-state-icon">✓</div> + <p>No organizations found with similar names.</p> + </div> + `; + return; + } + + content.innerHTML = ` + <table class="issue-table"> + <thead> + <tr> + <th>Name Pattern</th> + <th>Similar Organizations</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + ${similarNames.map(s => ` + <tr> + <td> + <span class="domain-name">${escapeHtml(s.base_name)}</span> + <br><small style="color: var(--color-text-muted);">${s.org_count} organizations</small> + </td> + <td> + <ul class="org-list"> + ${s.organizations.map(o => ` + <li> + <a href="/admin/accounts/${encodeURIComponent(o.org_id)}" class="org-link">${escapeHtml(o.name)}</a> + <span class="status-badge ${o.subscription_status === 'active' ? 'status-active' : 'status-prospect'}"> + ${escapeHtml(o.subscription_status || 'prospect')} + </span> + </li> + `).join('')} + </ul> + </td> + <td> + <button class="btn btn-primary btn-sm merge-orgs-btn" data-org-ids="${s.organizations.map(o => encodeURIComponent(o.org_id)).join(',')}" data-base-name="${encodeURIComponent(s.base_name)}"> + Review & Merge + </button> + </td> + </tr> + `).join('')} + </tbody> + </table> + `; + } + // Merge modal functionality let mergeModalElement = null; diff --git a/server/src/routes/admin/accounts.ts b/server/src/routes/admin/accounts.ts index 3ce004be67..f17b1a9a8a 100644 --- a/server/src/routes/admin/accounts.ts +++ b/server/src/routes/admin/accounts.ts @@ -315,6 +315,8 @@ export function setupAccountRoutes( stakeholdersResult, domainsResult, membersResult, + misalignedUsersResult, + similarOrgsResult, ] = await Promise.all([ // Working groups pool.query( @@ -438,6 +440,90 @@ export function setupAccountRoutes( `, [orgId] ), + + // Domain health: Users with this org's domains who are in personal workspaces + // (They should be in this org but aren't) + pool.query( + ` + WITH org_domains AS ( + -- Get all domains claimed by this org + SELECT domain FROM organization_domains WHERE workos_organization_id = $1 + ), + users_with_domain AS ( + -- Find users whose email domain matches one of this org's domains + SELECT DISTINCT + om.workos_user_id, + om.email, + om.first_name, + om.last_name, + om.workos_organization_id, + o.name as org_name, + o.is_personal, + LOWER(SUBSTRING(om.email FROM POSITION('@' IN om.email) + 1)) as user_domain + FROM organization_memberships om + JOIN organizations o ON om.workos_organization_id = o.workos_organization_id + JOIN org_domains od ON LOWER(SUBSTRING(om.email FROM POSITION('@' IN om.email) + 1)) = od.domain + ) + SELECT + workos_user_id as user_id, + email, + first_name, + last_name, + user_domain, + workos_organization_id as current_org_id, + org_name as current_org_name, + is_personal as in_personal_workspace + FROM users_with_domain + WHERE workos_organization_id != $1 -- Not already in this org + ORDER BY is_personal DESC, email ASC + `, + [orgId] + ), + + // Domain health: Similar organization names (potential duplicates) + pool.query( + ` + WITH this_org AS ( + SELECT + workos_organization_id, + name, + LOWER(REGEXP_REPLACE( + REGEXP_REPLACE(name, '\\s*(Inc\\.?|LLC|Corp\\.?|Ltd\\.?|Company|Co\\.?)\\s*$', '', 'i'), + '[^a-z0-9\\s]', '', 'g' + )) as normalized_name + FROM organizations + WHERE workos_organization_id = $1 + ), + other_orgs AS ( + SELECT + workos_organization_id, + name, + subscription_status, + is_personal, + LOWER(REGEXP_REPLACE( + REGEXP_REPLACE(name, '\\s*(Inc\\.?|LLC|Corp\\.?|Ltd\\.?|Company|Co\\.?)\\s*$', '', 'i'), + '[^a-z0-9\\s]', '', 'g' + )) as normalized_name + FROM organizations + WHERE workos_organization_id != $1 + AND is_personal = false + ) + SELECT + oo.workos_organization_id as org_id, + oo.name, + oo.subscription_status, + oo.normalized_name + FROM this_org t + JOIN other_orgs oo ON ( + oo.normalized_name = t.normalized_name + OR oo.normalized_name LIKE '%' || t.normalized_name || '%' + OR t.normalized_name LIKE '%' || oo.normalized_name || '%' + ) + WHERE LENGTH(t.normalized_name) >= 3 + ORDER BY oo.name ASC + `, + [orgId] + ), ]); // Get engagement signals @@ -574,6 +660,19 @@ export function setupAccountRoutes( working_groups: workingGroupResult.rows, stakeholders: stakeholdersResult.rows, domains: domainsResult.rows, + + // Domain health insights for this org + domain_health: { + // Users who have this org's domain but aren't in this org + misaligned_users: misalignedUsersResult.rows, + // Potential duplicate orgs with similar names + similar_orgs: similarOrgsResult.rows, + // Whether all domains are verified + has_unverified_domains: domainsResult.rows.some( + (d: { verified?: boolean }) => !d.verified + ), + }, + owner: owner ? { user_id: owner.user_id, diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 1c0782ac7f..7aeaa01f3e 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -1331,22 +1331,40 @@ export function setupDomainRoutes( WHERE o.is_personal = true AND om.email IS NOT NULL AND LOWER(SPLIT_PART(om.email, '@', 2)) NOT IN (${freeEmailPlaceholders}) + -- Exclude users who are already members of the target company org + AND NOT EXISTS ( + SELECT 1 FROM organization_memberships om2 + WHERE om2.workos_user_id = om.workos_user_id + AND om2.workos_organization_id = cd.target_org_id + ) ORDER BY LOWER(SPLIT_PART(om.email, '@', 2)), om.email LIMIT $${allExcludedDomains.length + 1} `, [...allExcludedDomains, limit]); // 3. Orgs without verified domains + // Also identify which user domains are already claimed by other orgs const unverifiedResult = await pool.query(` + WITH claimed_domains AS ( + SELECT LOWER(domain) as domain, workos_organization_id + FROM organization_domains + ) SELECT o.workos_organization_id, o.name, o.email_domain, o.subscription_status, COUNT(DISTINCT om.workos_user_id) as user_count, - array_agg(DISTINCT LOWER(SPLIT_PART(om.email, '@', 2))) FILTER (WHERE om.email IS NOT NULL) as user_domains + array_agg(DISTINCT LOWER(SPLIT_PART(om.email, '@', 2))) FILTER (WHERE om.email IS NOT NULL) as user_domains, + -- Domains that are claimed by OTHER orgs (not this one) + array_agg(DISTINCT cd.domain) FILTER ( + WHERE cd.domain IS NOT NULL + AND cd.workos_organization_id != o.workos_organization_id + AND cd.domain = LOWER(SPLIT_PART(om.email, '@', 2)) + ) as claimed_by_others FROM organizations o JOIN organization_memberships om ON om.workos_organization_id = o.workos_organization_id LEFT JOIN organization_domains od ON od.workos_organization_id = o.workos_organization_id AND od.verified = true + LEFT JOIN claimed_domains cd ON cd.domain = LOWER(SPLIT_PART(om.email, '@', 2)) WHERE o.is_personal = false AND od.id IS NULL GROUP BY o.workos_organization_id, o.name, o.email_domain, o.subscription_status @@ -1446,6 +1464,51 @@ export function setupDomainRoutes( LIMIT $1 `, [limit]); + // 7. Similar organization names - find orgs with similar names that might be duplicates + // Uses trigram similarity (requires pg_trgm extension) or simple word matching + const similarNamesResult = await pool.query(` + WITH org_names AS ( + SELECT + workos_organization_id, + name, + subscription_status, + is_personal, + -- Normalize name: lowercase, remove common suffixes, remove punctuation + LOWER(REGEXP_REPLACE( + REGEXP_REPLACE(name, '\\s*(Inc\\.?|LLC|Corp\\.?|Ltd\\.?|Company|Co\\.?)\\s*$', '', 'i'), + '[^a-z0-9\\s]', '', 'g' + )) as normalized_name + FROM organizations + WHERE is_personal = false + ), + name_groups AS ( + SELECT + o1.normalized_name as base_name, + array_agg(DISTINCT jsonb_build_object( + 'org_id', o1.workos_organization_id, + 'name', o1.name, + 'subscription_status', o1.subscription_status + )) as organizations, + COUNT(DISTINCT o1.workos_organization_id) as org_count + FROM org_names o1 + JOIN org_names o2 ON o1.workos_organization_id != o2.workos_organization_id + AND ( + -- Exact match on normalized name + o1.normalized_name = o2.normalized_name + -- Or one is substring of the other (e.g., "Yahoo" vs "Yahoo Inc") + OR o1.normalized_name LIKE '%' || o2.normalized_name || '%' + OR o2.normalized_name LIKE '%' || o1.normalized_name || '%' + ) + WHERE LENGTH(o1.normalized_name) >= 3 -- Avoid matching very short names + GROUP BY o1.normalized_name + HAVING COUNT(DISTINCT o1.workos_organization_id) > 1 + ) + SELECT base_name, org_count, organizations + FROM name_groups + ORDER BY org_count DESC, base_name + LIMIT $1 + `, [limit]); + res.json({ summary: { total_organizations: parseInt(statsResult.rows[0].total_orgs, 10), @@ -1458,6 +1521,7 @@ export function setupDomainRoutes( unverified_orgs: unverifiedResult.rows.length, domain_conflicts: conflictResult.rows.length, related_domains: relatedDomainsResult.rows.length, + similar_names: similarNamesResult.rows.length, }, }, orphan_domains: orphanResult.rows.map(row => ({ @@ -1488,6 +1552,7 @@ export function setupDomainRoutes( subscription_status: row.subscription_status, user_count: parseInt(row.user_count, 10), user_domains: row.user_domains?.filter(Boolean) || [], + claimed_by_others: row.claimed_by_others?.filter(Boolean) || [], })), domain_conflicts: conflictResult.rows.map(row => ({ domain: row.email_domain, @@ -1499,6 +1564,11 @@ export function setupDomainRoutes( org_count: parseInt(row.org_count, 10), organizations: row.organizations, })), + similar_names: similarNamesResult.rows.map(row => ({ + base_name: row.base_name, + org_count: parseInt(row.org_count, 10), + organizations: row.organizations, + })), }); } catch (error) { logger.error({ err: error }, "Error fetching domain health"); From 2d48e7519c388142686eb3c12fa3e83a977f5beb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 07:37:54 -0500 Subject: [PATCH 17/77] docs: fix authentication header inconsistency (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove incorrect x-adcp-auth header from principals-and-security.mdx - Use standard Authorization: Bearer header (consistent with MCP spec) - Remove implementation details from protocol security docs - Simplify authentication.mdx to Bearer tokens only - Add test agent config example consistent with testing.mdx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/polite-socks-swim.md | 2 + .../principals-and-security.mdx | 84 +++++++------------ docs/reference/authentication.mdx | 68 +++++++-------- 3 files changed, 62 insertions(+), 92 deletions(-) create mode 100644 .changeset/polite-socks-swim.md 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/docs/media-buy/advanced-topics/principals-and-security.mdx b/docs/media-buy/advanced-topics/principals-and-security.mdx index 913944f3f8..abe747a6ca 100644 --- a/docs/media-buy/advanced-topics/principals-and-security.mdx +++ b/docs/media-buy/advanced-topics/principals-and-security.mdx @@ -3,80 +3,52 @@ title: Principals & Security --- -A critical concept in AdCP is the **Principal**. A Principal represents a distinct client or buyer. The protocol is designed to be multi-tenant, and security is enforced through bearer token authentication. +A **Principal** represents a distinct client or buyer in AdCP. Sales agents use principals to identify who is making requests and enforce data isolation between different buyers. ## Authentication -All MCP requests must be authenticated using a bearer token. The client must include an `x-adcp-auth` header with each request: +All requests must be authenticated using a bearer token in the standard `Authorization` header: -`x-adcp-auth: <your_secret_token>` +``` +Authorization: Bearer <your_token> +``` -The server validates this token and associates it with both a specific `tenant_id` and `principal_id`. All subsequent operations within that request are scoped to that authenticated tenant and principal. +The server validates this token and associates it with a specific `principal_id`. All subsequent operations within that request are scoped to that authenticated principal. + +See [Authentication](/docs/reference/authentication) for details on obtaining credentials and authentication methods. ### The Principal Model -On the server, a Principal is defined by: -- **`principal_id`** (string): A unique identifier for the client (e.g., `"purina"`). -- **`platform_mappings`** (dict): A JSON object that maps the `principal_id` to identifiers in various ad serving platforms (e.g., `{"gam_advertiser_id": 12345}`). +A Principal is defined by: +- **`principal_id`** (string): A unique identifier for the client (e.g., `"acme_corp"`). +- **`platform_mappings`** (object): Maps the `principal_id` to identifiers in ad serving platforms (e.g., `{"gam_advertiser_id": 12345}`). ## Data Isolation -Authentication provides the foundation for strict data isolation. The server **MUST** enforce the following rules: +Authentication provides the foundation for strict data isolation. Sales agents **MUST** enforce the following rules: -1. When an object like a `MediaBuy` is created, it **MUST** be permanently associated with the `principal_id` from the authenticated request context. -2. For any subsequent request to read or modify that object, the server **MUST** verify that the `principal_id` from the new request's context matches the `principal_id` stored with the object. -3. If the IDs do not match, the server **MUST** return a permission denied error. +1. When an object like a `MediaBuy` is created, it **MUST** be permanently associated with the `principal_id` from the authenticated request context. +2. For any subsequent request to read or modify that object, the server **MUST** verify that the `principal_id` from the request matches the `principal_id` stored with the object. +3. If the IDs do not match, the server **MUST** return a permission denied error. This model ensures that one principal can never view or modify another principal's data, as they will not possess the correct bearer token to do so. Passing a `principal_id` in the request body is not required or respected; the identity is based solely on the validated token. -## Multi-Tenant Architecture - -AdCP supports full multi-tenant deployment, allowing a single instance to serve multiple publishers: - -### Tenant Model - -Each tenant represents a publisher with: -- **`tenant_id`**: Unique identifier for the publisher -- **`subdomain`**: Optional subdomain for routing (e.g., `sports.example.com`) -- **`config`**: JSON configuration including adapter settings, features, and limits -- **`admin_token`**: Special token for administrative operations - -### Tenant Isolation - -1. **Data Isolation**: All data (principals, products, media buys, creatives) is scoped by `tenant_id` -2. **Configuration Isolation**: Each tenant has independent adapter configuration -3. **Token Namespace**: Authentication tokens are unique within each tenant - -### Admin Operations - -Some tools are restricted to admin users with the tenant's admin token: -- `review_pending_creatives`: Approve/reject creative submissions -- `list_human_tasks`: View manual approval queue -- `complete_human_task`: Process manual approvals -- `get_all_media_buy_delivery`: View all media buys across principals +## Security Requirements -## Security Boundaries +### Required Security Measures -### Adapter Security +Sales agent implementations **MUST**: -Each ad server adapter enforces its own security perimeter: -- **Read vs Write**: Some adapters may have read-only access -- **Scope Limitations**: Access may be limited to specific accounts/networks -- **API Quotas**: Platform-specific rate limits and quotas +- Validate bearer tokens on every authenticated request +- Enforce principal-based data isolation +- Use TLS for all communications +- Log authentication failures for security monitoring -### Audit Logging +### Recommended Security Measures -All operations are logged to the database with: -- Timestamp -- Principal and tenant context -- Operation type and parameters -- Success/failure status -- Security-relevant events (auth failures, permission denials) -- Full request/response details for compliance +Sales agent implementations **SHOULD**: -The audit logging system provides: -- **Database Persistence**: All logs stored in `audit_logs` table with tenant isolation -- **File Backup**: Redundant file-based logging for disaster recovery -- **Real-time Monitoring**: Operations dashboard shows audit trail with filtering -- **Security Alerts**: Highlights authentication failures and permission violations -- **Compliance Ready**: Full audit trail for regulatory requirements +- Implement rate limiting per principal +- Support token expiration and refresh +- Provide audit logging for compliance +- Support IP allowlisting for high-security principals diff --git a/docs/reference/authentication.mdx b/docs/reference/authentication.mdx index 02247e0d7b..44402d4f8e 100644 --- a/docs/reference/authentication.mdx +++ b/docs/reference/authentication.mdx @@ -38,36 +38,31 @@ These operations require valid credentials: **Rationale**: These operations involve financial commitments, access to proprietary data, or modifications to active campaigns. -## Authentication Methods +## Authentication Method -AdCP supports multiple authentication methods. Implementations must support at least one method: +AdCP uses Bearer token authentication, consistent with the MCP specification: -### JWT Bearer Token -```http -Authorization: Bearer <jwt_token> ``` +Authorization: Bearer <token> +``` + +Tokens may be: +- **Opaque tokens**: Server-validated strings mapped to principals +- **JWT tokens**: Self-contained tokens with embedded claims + +### JWT Token Claims + +When using JWT tokens, include these standard claims: -JWT tokens must include standard claims: ```json { "sub": "principal_123", "exp": 1706745600, - "iat": 1706742000, - "permissions": { - "products": ["read"], - "media_buys": ["read", "write"], - "creatives": ["read", "write"], - "reports": ["read"] - } + "iat": 1706742000 } ``` -### API Key -```http -X-API-Key: <api_key> -``` - -API keys are mapped to principals and their associated permissions. +Sales agents may require additional claims for authorization. ## Principal Model @@ -85,26 +80,21 @@ interface Principal { type Permission = 'read' | 'write' | 'delete' | 'approve'; ``` -## Required Headers by Protocol +## Protocol Configuration -### MCP -```json -{ - "headers": { - "Authorization": "Bearer <token>" - } -} -``` +Both MCP and A2A protocols use the same authentication header. Configure your client with: -### A2A ```json { - "headers": { - "Authorization": "Bearer <token>" + "auth": { + "type": "bearer", + "token": "<your_token>" } } ``` +The client library handles adding the `Authorization: Bearer <token>` header to requests. + ## Obtaining Credentials ### Account Setup Process @@ -189,11 +179,17 @@ Consider using aggregation platforms (like Scope3) that manage credentials and r ## Testing Authentication -Use dry run mode to test authenticated operations without affecting production: +Use the public test agent to validate your authentication setup: -```http -X-Dry-Run: true -Authorization: Bearer <token> +```json +{ + "agent_uri": "https://test-agent.adcontextprotocol.org/mcp", + "protocol": "mcp", + "auth": { + "type": "bearer", + "token": "1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" + } +} ``` -See [Testing & Development Guide](/docs/media-buy/advanced-topics/testing) for complete testing capabilities. \ No newline at end of file +See [Testing & Development Guide](/docs/media-buy/advanced-topics/testing) for complete testing capabilities including dry run mode and time simulation. \ No newline at end of file From b74388e09ff5827a2a42d701833a4088ad4ee01f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:06:00 -0500 Subject: [PATCH 18/77] feat: show claiming org info for domains in domain health admin (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When viewing "Organizations Without Verified Domains", domains that are claimed by another organization now show which org claims them with a clickable link. This helps admins understand why a domain can't be automatically linked and navigate to investigate/resolve conflicts. Changes: - Update SQL query to return claiming org details (name, id) instead of just domain names - Update UI to show claimed domains with an amber tag linking to the claiming org's admin page - Add "personal" tag for personal email domains - Use design system CSS variables for styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/nine-rings-beam.md | 2 ++ server/public/admin-domain-health.html | 44 ++++++++++++++++++++++++-- server/src/routes/admin/domains.ts | 13 +++++--- 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 .changeset/nine-rings-beam.md 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/server/public/admin-domain-health.html b/server/public/admin-domain-health.html index c35d5c7d3f..1f8baa0dea 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); @@ -1058,10 +1080,17 @@ <h1>Domain Health</h1> </thead> <tbody> ${unverified_orgs.map(o => { + // 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 claimedByOthers = new Set((o.claimed_by_others || []).map(d => d.toLowerCase())); const corporateDomains = (o.user_domains || []).filter(d => - d && !personalDomains.includes(d.toLowerCase()) && !claimedByOthers.has(d.toLowerCase()) + d && !personalDomains.includes(d.toLowerCase()) && !claimedDomainsMap.has(d.toLowerCase()) ); const suggestedDomain = corporateDomains[0] || null; @@ -1074,7 +1103,16 @@ <h1>Domain Health</h1> <td><span class="user-count">${o.user_count}</span></td> <td> <ul class="user-list"> - ${o.user_domains.slice(0, 3).map(d => `<li>${escapeHtml(d)}</li>`).join('')} + ${o.user_domains.slice(0, 3).map(d => { + const claim = claimedDomainsMap.get(d.toLowerCase()); + if (claim) { + return `<li>${escapeHtml(d)} <span class="claimed-tag" title="Claimed by ${escapeHtml(claim.org_name)}"><a href="/admin/accounts/${encodeURIComponent(claim.org_id)}" style="color: inherit; text-decoration: none;">→ ${escapeHtml(claim.org_name)}</a></span></li>`; + } + if (personalDomains.includes(d.toLowerCase())) { + return `<li>${escapeHtml(d)} <span class="personal-tag">personal</span></li>`; + } + return `<li>${escapeHtml(d)}</li>`; + }).join('')} </ul> </td> <td> diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 7aeaa01f3e..7fd29c0dff 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -1345,8 +1345,9 @@ export function setupDomainRoutes( // Also identify which user domains are already claimed by other orgs const unverifiedResult = await pool.query(` WITH claimed_domains AS ( - SELECT LOWER(domain) as domain, workos_organization_id - FROM organization_domains + SELECT LOWER(od.domain) as domain, od.workos_organization_id, org.name as claiming_org_name + FROM organization_domains od + JOIN organizations org ON org.workos_organization_id = od.workos_organization_id ) SELECT o.workos_organization_id, @@ -1355,8 +1356,12 @@ export function setupDomainRoutes( o.subscription_status, COUNT(DISTINCT om.workos_user_id) as user_count, array_agg(DISTINCT LOWER(SPLIT_PART(om.email, '@', 2))) FILTER (WHERE om.email IS NOT NULL) as user_domains, - -- Domains that are claimed by OTHER orgs (not this one) - array_agg(DISTINCT cd.domain) FILTER ( + -- Domains that are claimed by OTHER orgs (not this one) with org details + json_agg(DISTINCT jsonb_build_object( + 'domain', cd.domain, + 'org_id', cd.workos_organization_id, + 'org_name', cd.claiming_org_name + )) FILTER ( WHERE cd.domain IS NOT NULL AND cd.workos_organization_id != o.workos_organization_id AND cd.domain = LOWER(SPLIT_PART(om.email, '@', 2)) From cc63cbe5046e5548fd28d981c17abd530755c442 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:06:12 -0500 Subject: [PATCH 19/77] fix: exclude free email providers from related domains in domain health (#694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gmail.com and other free email providers were appearing in the "Related Organizations" section of the domain health dashboard, incorrectly grouping unrelated organizations together just because some of their users had Gmail addresses. Added filtering to the relatedDomainsResult query to exclude: - Hardcoded free email domains (gmail.com, yahoo.com, hotmail.com, etc.) - Admin-managed personal domains from the database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/block-gmail-corp-domain.md | 4 ++++ server/src/routes/admin/domains.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/block-gmail-corp-domain.md 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/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 7fd29c0dff..9cf940f751 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -1444,6 +1444,8 @@ export function setupDomainRoutes( SUBSTRING(domain FROM '([^.]+\\.[^.]+)$') as root_domain FROM org_domains WHERE domain IS NOT NULL + -- Exclude personal email domains (gmail.com, yahoo.com, admin-managed domains, etc.) + AND LOWER(SUBSTRING(domain FROM '([^.]+\\.[^.]+)$')) NOT IN (${freeEmailPlaceholders}) ), root_groups AS ( -- Group by root domain, only keep groups with multiple orgs @@ -1466,8 +1468,8 @@ export function setupDomainRoutes( ) SELECT * FROM root_groups ORDER BY org_count DESC - LIMIT $1 - `, [limit]); + LIMIT $${allExcludedDomains.length + 1} + `, [...allExcludedDomains, limit]); // 7. Similar organization names - find orgs with similar names that might be duplicates // Uses trigram similarity (requires pg_trgm extension) or simple word matching From 0ae7162e0cda149dc90a9b828167a3256fceb3c2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:07:05 -0500 Subject: [PATCH 20/77] feat: invoice discount display and two-step draft flow (#693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add discount banner showing percent/amount discounts on invoice products - Display strikethrough list prices with discounted net prices - Show "Recommended" badge for products matching org's revenue tier - Implement two-step invoice flow: Prepare Draft → Review → Send - Add draft review screen showing all invoice details before sending - Fix XSS vulnerability by replacing inline onclick handlers with data attributes and event listeners - Sort invoice products by price (lowest first) for better UX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/every-moose-kick.md | 2 + server/public/admin-org-detail.html | 330 ++++++++++++++++++- server/public/dashboard.html | 471 +++++++++++++++++++++++++++- server/src/addie/mcp/admin-tools.ts | 137 +++++++- server/src/routes/admin.ts | 7 +- server/src/routes/billing-public.ts | 8 + 6 files changed, 923 insertions(+), 32 deletions(-) create mode 100644 .changeset/every-moose-kick.md 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/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 @@ <h4 style="margin-top: var(--space-4); margin-bottom: var(--space-3); font-size: </div> </div> + <!-- Draft Review Section (shown after billing details, before sending) --> + <div id="invoiceDraftReview" style="display: none;"> + <div style="background: var(--color-info-50); border: 1px solid var(--color-info-200); border-radius: var(--radius-md); padding: var(--space-4); margin-bottom: var(--space-4);"> + <strong style="color: var(--color-info-700);">Review Invoice Draft</strong> + <p style="margin: var(--space-2) 0 0; font-size: var(--text-sm); color: var(--color-text-secondary);"> + Please review all details before sending. The invoice will be emailed to the contact. + </p> + </div> + + <div id="draftReviewContent" style="background: var(--color-bg-subtle); border-radius: var(--radius-md); padding: var(--space-4); margin-bottom: var(--space-4);"> + <!-- Populated by JavaScript --> + </div> + + <div id="draftMissingFields" style="display: none; background: var(--color-warning-50); border: 1px solid var(--color-warning-200); border-radius: var(--radius-md); padding: var(--space-3); margin-bottom: var(--space-4);"> + <strong style="color: var(--color-warning-700); font-size: var(--text-sm);">Missing Information</strong> + <ul id="missingFieldsList" style="margin: var(--space-2) 0 0; padding-left: var(--space-4); font-size: var(--text-sm); color: var(--color-warning-700);"></ul> + </div> + </div> + <!-- Success Message --> <div id="invoiceResult" style="display: none;"> <div style="background: var(--color-success-50); border: 1px solid var(--color-success-200); border-radius: var(--radius-md); padding: var(--space-4); margin-bottom: var(--space-4);"> @@ -1372,6 +1391,8 @@ <h4 style="margin-top: var(--space-4); margin-bottom: var(--space-3); font-size: <div class="modal-actions"> <button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Close</button> + <button type="button" class="btn btn-secondary" id="backToEditBtn" style="display: none;" onclick="backToEditInvoice()">Edit Details</button> + <button type="button" class="btn btn-primary" id="prepareDraftBtn" style="display: none;" onclick="prepareDraft()">Prepare Draft</button> <button type="submit" class="btn btn-primary" id="sendInvoiceBtn" style="display: none;">Send Invoice</button> </div> </form> @@ -3351,8 +3372,11 @@ <h2 id="discountModalTitle">Set Discount</h2> document.getElementById('invoiceProducts').innerHTML = 'Loading products...'; document.getElementById('invoiceProductSection').style.display = 'block'; document.getElementById('invoiceBillingSection').style.display = 'none'; + document.getElementById('invoiceDraftReview').style.display = 'none'; document.getElementById('invoiceResult').style.display = 'none'; document.getElementById('sendInvoiceBtn').style.display = 'none'; + document.getElementById('prepareDraftBtn').style.display = 'none'; + document.getElementById('backToEditBtn').style.display = 'none'; document.getElementById('invoiceForm').reset(); // Pre-fill from org data @@ -3375,6 +3399,9 @@ <h2 id="discountModalTitle">Set Discount</h2> const data = await response.json(); const invoiceableProducts = data.products.filter(p => p.is_invoiceable); + // Sort products by price (lowest first for better UX) + invoiceableProducts.sort((a, b) => (a.amount_cents || 0) - (b.amount_cents || 0)); + renderInvoiceProducts(invoiceableProducts); } catch (error) { console.error('Error loading products:', error); @@ -3383,6 +3410,53 @@ <h2 id="discountModalTitle">Set Discount</h2> } } + function getRecommendedInvoiceProduct(products) { + // Get the org's revenue tier + let orgRevenueTier = orgData?.revenue_tier; + + // If no revenue_tier set, try to derive from enrichment data + if (!orgRevenueTier && orgData?.enrichment_revenue_range) { + const rangeMap = { + 'under $1m': 'under_1m', + '$1m-$5m': '1m_5m', + '$5m-$50m': '5m_50m', + '$50m-$250m': '50m_250m', + '$250m-$1b': '250m_1b', + 'over $1b': '1b_plus' + }; + const lowerRange = orgData.enrichment_revenue_range.toLowerCase(); + for (const [key, value] of Object.entries(rangeMap)) { + if (lowerRange.includes(key.replace('$', '').replace('-', ' to '))) { + orgRevenueTier = value; + break; + } + } + } + + if (!orgRevenueTier) return null; + + // Find products that match the org's revenue tier + const matchingProducts = products.filter(p => { + if (!p.revenue_tiers || p.revenue_tiers.length === 0) return true; + return p.revenue_tiers.includes(orgRevenueTier); + }); + + // Return highest priced matching product (same logic as pricing section) + if (matchingProducts.length > 0) { + return matchingProducts.reduce((a, b) => (b.amount_cents || 0) > (a.amount_cents || 0) ? b : a); + } + return null; + } + + function calculateDiscountedPrice(amountCents) { + if (orgData?.discount_percent) { + return Math.round(amountCents * (1 - orgData.discount_percent / 100)); + } else if (orgData?.discount_amount_cents) { + return Math.max(0, amountCents - orgData.discount_amount_cents); + } + return amountCents; + } + function renderInvoiceProducts(products) { const container = document.getElementById('invoiceProducts'); @@ -3391,54 +3465,277 @@ <h2 id="discountModalTitle">Set Discount</h2> return; } - container.innerHTML = products.map(product => { - const price = new Intl.NumberFormat('en-US', { + const recommendedProduct = getRecommendedInvoiceProduct(products); + const hasDiscount = orgData?.discount_percent || orgData?.discount_amount_cents; + + // Show discount info at top if applicable + let headerHtml = ''; + if (hasDiscount) { + const discountText = orgData.discount_percent + ? `${orgData.discount_percent}% discount` + : `${formatPriceCurrency(orgData.discount_amount_cents)} discount`; + headerHtml = ` + <div style="margin-bottom: var(--space-3); padding: var(--space-2) var(--space-3); background: var(--color-success-50); border-radius: var(--radius-md); border: 1px solid var(--color-success-200);"> + <span style="color: var(--color-success-700); font-weight: var(--font-medium);"> + ${discountText} will be applied + </span> + </div> + `; + } + + container.innerHTML = headerHtml + products.map(product => { + const listPrice = product.amount_cents || 0; + const netPrice = hasDiscount ? calculateDiscountedPrice(listPrice) : listPrice; + + const listPriceFormatted = new Intl.NumberFormat('en-US', { style: 'currency', - currency: 'USD' - }).format(product.amount_cents / 100); + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(listPrice / 100); + + const netPriceFormatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(netPrice / 100); const interval = product.billing_interval ? `/${product.billing_interval}` : ''; const displayName = escapeHtml(product.display_name || product.product_name); const lookupKey = escapeHtml(product.lookup_key); const description = product.description ? escapeHtml(product.description) : ''; + const isRecommended = recommendedProduct && product.lookup_key === recommendedProduct.lookup_key; + + // Build price display + let priceHtml; + if (hasDiscount && listPrice !== netPrice) { + priceHtml = ` + <span style="text-decoration: line-through; color: var(--color-text-muted); margin-right: var(--space-2);">${listPriceFormatted}</span> + <span style="color: var(--color-success-600); font-weight: var(--font-semibold);">${netPriceFormatted}${interval}</span> + `; + } else { + priceHtml = `<span style="color: var(--color-brand); font-weight: var(--font-semibold);">${listPriceFormatted}${interval}</span>`; + } + + // Build recommended badge + const recommendedBadge = isRecommended + ? `<span style="display: inline-block; margin-left: var(--space-2); padding: 2px 8px; font-size: var(--text-xs); background: var(--color-brand); color: white; border-radius: var(--radius-full);">Recommended</span>` + : ''; + + const borderColor = isRecommended ? 'var(--color-brand)' : 'var(--color-border)'; + const borderWidth = isRecommended ? '2px' : '1px'; return ` - <button type="button" class="product-select-btn" onclick="selectInvoiceProduct('${lookupKey}', '${displayName}', ${product.amount_cents})" style=" + <button type="button" class="product-select-btn" + data-lookup-key="${lookupKey}" + data-display-name="${displayName}" + data-amount="${listPrice}" + data-border-color="${borderColor}" + style=" display: block; width: 100%; text-align: left; padding: var(--space-3); margin-bottom: var(--space-2); - border: 1px solid var(--color-border); + border: ${borderWidth} solid ${borderColor}; border-radius: var(--radius-md); background: var(--color-bg-card); cursor: pointer; transition: var(--transition-all); - " onmouseover="this.style.borderColor='var(--color-brand)'" onmouseout="this.style.borderColor='var(--color-border)'"> - <strong>${displayName}</strong> + "> + <strong>${displayName}</strong>${recommendedBadge} <br> - <span style="color: var(--color-brand); font-weight: var(--font-semibold);">${price}${interval}</span> + ${priceHtml} ${description ? `<br><span style="font-size: var(--text-xs); color: var(--color-text-muted);">${description}</span>` : ''} </button> `; }).join(''); + + // Add event listeners to avoid XSS from inline onclick + container.querySelectorAll('.product-select-btn').forEach(btn => { + btn.addEventListener('click', () => { + selectInvoiceProduct( + btn.dataset.lookupKey, + btn.dataset.displayName, + parseInt(btn.dataset.amount, 10) + ); + }); + btn.addEventListener('mouseover', () => { + btn.style.borderColor = 'var(--color-brand)'; + }); + btn.addEventListener('mouseout', () => { + btn.style.borderColor = btn.dataset.borderColor; + }); + }); } function selectInvoiceProduct(lookupKey, productName, amountCents) { selectedInvoiceProduct = { lookupKey, productName, amountCents }; - const price = new Intl.NumberFormat('en-US', { + const hasDiscount = orgData?.discount_percent || orgData?.discount_amount_cents; + const netPrice = hasDiscount ? calculateDiscountedPrice(amountCents) : amountCents; + + const listPriceFormatted = new Intl.NumberFormat('en-US', { style: 'currency', - currency: 'USD' + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 }).format(amountCents / 100); + const netPriceFormatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(netPrice / 100); + + let priceDisplay; + if (hasDiscount && amountCents !== netPrice) { + priceDisplay = ` + <span style="text-decoration: line-through; color: var(--color-text-muted);">${listPriceFormatted}</span> + <span style="color: var(--color-success-600); font-weight: var(--font-semibold); margin-left: var(--space-2);">${netPriceFormatted}</span> + `; + } else { + priceDisplay = `<span style="color: var(--color-brand);">${listPriceFormatted}</span>`; + } + document.getElementById('selectedProductDisplay').innerHTML = ` - <strong>${productName}</strong> - <span style="color: var(--color-brand);">${price}</span> + <strong>${productName}</strong> - ${priceDisplay} `; document.getElementById('invoiceProductSection').style.display = 'none'; document.getElementById('invoiceBillingSection').style.display = 'block'; - document.getElementById('sendInvoiceBtn').style.display = 'inline-block'; + document.getElementById('invoiceDraftReview').style.display = 'none'; + document.getElementById('prepareDraftBtn').style.display = 'inline-block'; + document.getElementById('sendInvoiceBtn').style.display = 'none'; + document.getElementById('backToEditBtn').style.display = 'none'; + } + + function prepareDraft() { + // Collect all invoice data for review + const contactName = document.getElementById('invoiceContactName').value; + const contactEmail = document.getElementById('invoiceContactEmail').value; + const companyName = document.getElementById('invoiceCompanyName').value; + const addressLine1 = document.getElementById('invoiceAddressLine1').value; + const addressLine2 = document.getElementById('invoiceAddressLine2').value; + const city = document.getElementById('invoiceCity').value; + const state = document.getElementById('invoiceState').value; + const postalCode = document.getElementById('invoicePostalCode').value; + const country = document.getElementById('invoiceCountry').value; + + // Check for missing required fields + const missingFields = []; + if (!contactName) missingFields.push('Contact Name'); + if (!contactEmail) missingFields.push('Contact Email'); + if (!companyName) missingFields.push('Company Name'); + if (!addressLine1) missingFields.push('Street Address'); + if (!city) missingFields.push('City'); + if (!state) missingFields.push('State/Province'); + if (!postalCode) missingFields.push('Postal Code'); + if (!country) missingFields.push('Country'); + + // Calculate pricing + const hasDiscount = orgData?.discount_percent || orgData?.discount_amount_cents; + const listPrice = selectedInvoiceProduct.amountCents; + const netPrice = hasDiscount ? calculateDiscountedPrice(listPrice) : listPrice; + + const listPriceFormatted = new Intl.NumberFormat('en-US', { + style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 + }).format(listPrice / 100); + + const netPriceFormatted = new Intl.NumberFormat('en-US', { + style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 + }).format(netPrice / 100); + + // Build discount info + let discountHtml = ''; + if (hasDiscount && listPrice !== netPrice) { + const discountText = orgData.discount_percent + ? `${orgData.discount_percent}% discount` + : `${formatPriceCurrency(orgData.discount_amount_cents)} off`; + const savingsFormatted = new Intl.NumberFormat('en-US', { + style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 + }).format((listPrice - netPrice) / 100); + discountHtml = ` + <div style="margin-top: var(--space-2); padding: var(--space-2); background: var(--color-success-50); border-radius: var(--radius-sm);"> + <span style="color: var(--color-success-700); font-size: var(--text-sm);"> + ${discountText} applied (saves ${savingsFormatted}) + </span> + </div> + `; + } + + // Build address display + const addressParts = [addressLine1]; + if (addressLine2) addressParts.push(addressLine2); + addressParts.push(`${city}, ${state} ${postalCode}`); + addressParts.push(country); + const fullAddress = addressParts.filter(Boolean).join('<br>'); + + // Populate review content + document.getElementById('draftReviewContent').innerHTML = ` + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4);"> + <div> + <div style="font-size: var(--text-xs); color: var(--color-text-muted); text-transform: uppercase; margin-bottom: var(--space-1);">Company</div> + <div style="font-weight: var(--font-semibold);">${escapeHtml(companyName) || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + </div> + <div> + <div style="font-size: var(--text-xs); color: var(--color-text-muted); text-transform: uppercase; margin-bottom: var(--space-1);">Contact</div> + <div style="font-weight: var(--font-semibold);">${escapeHtml(contactName) || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + <div style="color: var(--color-text-secondary); font-size: var(--text-sm);">${escapeHtml(contactEmail) || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + </div> + </div> + + <hr style="margin: var(--space-4) 0; border: none; border-top: 1px solid var(--color-border);"> + + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4);"> + <div> + <div style="font-size: var(--text-xs); color: var(--color-text-muted); text-transform: uppercase; margin-bottom: var(--space-1);">Product</div> + <div style="font-weight: var(--font-semibold);">${escapeHtml(selectedInvoiceProduct.productName)}</div> + </div> + <div> + <div style="font-size: var(--text-xs); color: var(--color-text-muted); text-transform: uppercase; margin-bottom: var(--space-1);">Amount</div> + ${hasDiscount && listPrice !== netPrice + ? `<div><span style="text-decoration: line-through; color: var(--color-text-muted);">${listPriceFormatted}</span> <span style="color: var(--color-success-600); font-weight: var(--font-semibold);">${netPriceFormatted}</span></div>` + : `<div style="font-weight: var(--font-semibold);">${listPriceFormatted}</div>` + } + ${discountHtml} + </div> + </div> + + <hr style="margin: var(--space-4) 0; border: none; border-top: 1px solid var(--color-border);"> + + <div> + <div style="font-size: var(--text-xs); color: var(--color-text-muted); text-transform: uppercase; margin-bottom: var(--space-1);">Billing Address</div> + <div style="line-height: 1.5;">${fullAddress || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + </div> + `; + + // Show/hide missing fields warning + const missingFieldsDiv = document.getElementById('draftMissingFields'); + const missingFieldsList = document.getElementById('missingFieldsList'); + if (missingFields.length > 0) { + missingFieldsList.innerHTML = missingFields.map(f => `<li>${f}</li>`).join(''); + missingFieldsDiv.style.display = 'block'; + } else { + missingFieldsDiv.style.display = 'none'; + } + + // Update UI state + document.getElementById('invoiceBillingSection').style.display = 'none'; + document.getElementById('invoiceDraftReview').style.display = 'block'; + document.getElementById('prepareDraftBtn').style.display = 'none'; + document.getElementById('backToEditBtn').style.display = 'inline-block'; + document.getElementById('sendInvoiceBtn').style.display = missingFields.length === 0 ? 'inline-block' : 'none'; + } + + function backToEditInvoice() { + document.getElementById('invoiceDraftReview').style.display = 'none'; + document.getElementById('invoiceBillingSection').style.display = 'block'; + document.getElementById('prepareDraftBtn').style.display = 'inline-block'; + document.getElementById('backToEditBtn').style.display = 'none'; + document.getElementById('sendInvoiceBtn').style.display = 'none'; } async function submitInvoice(event) { @@ -3468,6 +3765,11 @@ <h2 id="discountModalTitle">Set Discount</h2> } }; + // Include coupon_id if the org has a discount configured + if (orgData?.stripe_coupon_id) { + data.coupon_id = orgData.stripe_coupon_id; + } + try { const response = await fetch(`/api/admin/prospects/${orgId}/invoice`, { method: 'POST', @@ -3484,7 +3786,9 @@ <h2 id="discountModalTitle">Set Discount</h2> if (result.success && result.invoice_url) { document.getElementById('invoiceBillingSection').style.display = 'none'; + document.getElementById('invoiceDraftReview').style.display = 'none'; document.getElementById('sendInvoiceBtn').style.display = 'none'; + document.getElementById('backToEditBtn').style.display = 'none'; document.getElementById('invoiceUrl').value = result.invoice_url; document.getElementById('invoiceResult').style.display = 'block'; diff --git a/server/public/dashboard.html b/server/public/dashboard.html index 2c432dbdda..26fbb00a38 100644 --- a/server/public/dashboard.html +++ b/server/public/dashboard.html @@ -1613,6 +1613,11 @@ <h3>Join the Agentic Advertising Organization</h3> const customerType = org.is_personal ? 'individual' : 'company'; const revenueTier = org.revenue_tier || ''; + // Get discount info from billing data + const discountPercent = org.billing?.discount_percent || null; + const discountAmountCents = org.billing?.discount_amount_cents || null; + const hasDiscount = discountPercent || discountAmountCents; + // Fetch products filtered by customer type and revenue tier const url = new URL('/api/billing-products', window.location.origin); url.searchParams.set('customer_type', customerType); @@ -1637,25 +1642,70 @@ <h3>Join the Agentic Advertising Organization</h3> return; } + // Helper to calculate discounted price + function calculateDiscountedPrice(amountCents) { + if (discountPercent) { + return Math.round(amountCents * (1 - discountPercent / 100)); + } else if (discountAmountCents) { + return Math.max(0, amountCents - discountAmountCents); + } + return amountCents; + } + + // Build discount banner if applicable + let discountBanner = ''; + if (hasDiscount) { + const discountText = discountPercent + ? `${discountPercent}% discount` + : formatCurrency(discountAmountCents) + ' discount'; + discountBanner = ` + <div style="margin-bottom: 16px; padding: 12px 16px; background: linear-gradient(135deg, var(--color-success-50), var(--color-success-100)); border-radius: 8px; border: 1px solid var(--color-success-200); text-align: center;"> + <span style="color: var(--color-success-700); font-weight: 600;"> + Your organization has a ${discountText} applied to membership + </span> + </div> + `; + } + // Render product cards const productCards = products.map(product => { - const price = formatCurrency(product.amount_cents, product.currency); + const listPrice = product.amount_cents; + const netPrice = hasDiscount ? calculateDiscountedPrice(listPrice) : listPrice; + const listPriceFormatted = formatCurrency(listPrice, product.currency); + const netPriceFormatted = formatCurrency(netPrice, product.currency); const interval = product.billing_interval === 'year' ? '/year' : product.billing_interval === 'month' ? '/month' : ''; const isSubscription = product.billing_type === 'subscription'; - return ` - <div class="product-card" style="border: 2px solid var(--color-border); border-radius: 12px; padding: 24px; text-align: center; transition: all 0.2s; cursor: pointer;" - onmouseover="this.style.borderColor='var(--aao-primary)'; this.style.boxShadow='0 4px 12px rgba(102, 126, 234, 0.15)';" - onmouseout="this.style.borderColor='var(--color-border)'; this.style.boxShadow='none';"> - <h3 style="margin: 0 0 8px 0; font-size: 18px; color: var(--color-text-heading);">${escapeHtml(product.display_name)}</h3> - ${product.description ? `<p style="margin: 0 0 16px 0; font-size: 14px; color: var(--color-text-secondary);">${escapeHtml(product.description)}</p>` : ''} + // Build price display + let priceDisplay; + if (hasDiscount && listPrice !== netPrice) { + priceDisplay = ` <div style="margin: 16px 0;"> - <span style="font-size: 32px; font-weight: 700; color: var(--color-text-heading);">${price}</span> + <div style="margin-bottom: 4px;"> + <span style="font-size: 18px; text-decoration: line-through; color: var(--color-text-muted);">${listPriceFormatted}</span> + </div> + <span style="font-size: 32px; font-weight: 700; color: var(--color-success-600);">${netPriceFormatted}</span> <span style="font-size: 14px; color: var(--color-text-secondary);">${interval}</span> </div> + `; + } else { + priceDisplay = ` + <div style="margin: 16px 0;"> + <span style="font-size: 32px; font-weight: 700; color: var(--color-text-heading);">${listPriceFormatted}</span> + <span style="font-size: 14px; color: var(--color-text-secondary);">${interval}</span> + </div> + `; + } + + return ` + <div class="product-card" style="border: 2px solid var(--color-border); border-radius: 12px; padding: 24px; text-align: center; transition: all 0.2s; cursor: pointer;"> + <h3 style="margin: 0 0 8px 0; font-size: 18px; color: var(--color-text-heading);">${escapeHtml(product.display_name)}</h3> + ${product.description ? `<p style="margin: 0 0 16px 0; font-size: 14px; color: var(--color-text-secondary);">${escapeHtml(product.description)}</p>` : ''} + ${priceDisplay} <button - onclick="startCheckout('${product.price_id}', '${org.id}')" - class="btn btn-primary" + data-price-id="${escapeHtml(product.price_id)}" + data-org-id="${escapeHtml(org.id)}" + class="btn btn-primary checkout-btn" style="width: 100%; padding: 12px 24px; font-size: 16px;" > ${isSubscription ? 'Subscribe' : 'Purchase'} @@ -1665,10 +1715,28 @@ <h3 style="margin: 0 0 8px 0; font-size: 18px; color: var(--color-text-heading); }).join(''); container.innerHTML = ` + ${discountBanner} <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-bottom: 20px;"> ${productCards} </div> `; + + // Add event listeners to avoid XSS from inline handlers + container.querySelectorAll('.product-card').forEach(card => { + card.addEventListener('mouseover', () => { + card.style.borderColor = 'var(--aao-primary)'; + card.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.15)'; + }); + card.addEventListener('mouseout', () => { + card.style.borderColor = 'var(--color-border)'; + card.style.boxShadow = 'none'; + }); + }); + container.querySelectorAll('.checkout-btn').forEach(btn => { + btn.addEventListener('click', () => { + startCheckout(btn.dataset.priceId, btn.dataset.orgId); + }); + }); } catch (error) { console.error('Error loading products:', error); container.innerHTML = ` @@ -2673,6 +2741,10 @@ <h3 style="margin: 0 0 8px 0; color: var(--color-text-heading);">Create Your Pro is_personal: billingData.is_personal, suggested_company_type: billingData.suggested_company_type, suggested_revenue_tier: billingData.suggested_revenue_tier, + // Discount info + discount_percent: billingData.discount_percent, + discount_amount_cents: billingData.discount_amount_cents, + stripe_promotion_code: billingData.stripe_promotion_code, }, stripeCustomerId: billingData.stripe_customer_id, customerSessionSecret: billingData.customer_session_secret, @@ -3303,6 +3375,385 @@ <h3 style="margin: 0 0 8px 0; color: var(--color-text-heading);">Create Your Pro window.history.replaceState({}, '', newUrl); } }); + + // ========================================================================= + // Invoice Request Modal Functions (Two-Step Draft Process) + // ========================================================================= + + let invoiceProducts = []; + let selectedInvoiceProductData = null; + + function openInvoiceRequestModal(options = {}) { + const modal = document.getElementById('invoiceRequestModal'); + const productSelect = document.getElementById('invoice-product'); + + // Reset modal state + document.getElementById('invoiceFormSection').style.display = 'block'; + document.getElementById('invoiceDraftReviewSection').style.display = 'none'; + document.getElementById('invoiceSuccessSection').style.display = 'none'; + document.getElementById('invoiceFormError').style.display = 'none'; + document.getElementById('invoiceRequestForm').reset(); + document.getElementById('prepareDraftInvoiceBtn').style.display = 'inline-block'; + document.getElementById('backToEditInvoiceBtn').style.display = 'none'; + document.getElementById('sendInvoiceRequestBtn').style.display = 'none'; + + // Pre-fill company name if provided + if (options.companyName) { + document.getElementById('invoice-company').value = options.companyName; + } + + // Load products and populate dropdown + loadInvoiceProducts(options.selectedProduct); + + modal.style.display = 'flex'; + document.getElementById('invoice-company').focus(); + } + + async function loadInvoiceProducts(preSelectedKey) { + const productSelect = document.getElementById('invoice-product'); + + try { + const response = await fetch('/api/products'); + if (!response.ok) throw new Error('Failed to load products'); + + const data = await response.json(); + invoiceProducts = (data.products || []).filter(p => p.billing_type === 'subscription' && p.is_invoiceable); + + // Sort by price + invoiceProducts.sort((a, b) => (a.amount_cents || 0) - (b.amount_cents || 0)); + + // Check if org has discount + const hasDiscount = currentOrg?.billing?.discount_percent || currentOrg?.billing?.discount_amount_cents; + + productSelect.innerHTML = '<option value="">Select a product...</option>'; + invoiceProducts.forEach(product => { + const listPrice = product.amount_cents || 0; + const netPrice = hasDiscount ? calculateInvoiceDiscountedPrice(listPrice) : listPrice; + const priceText = hasDiscount && netPrice !== listPrice + ? `${formatInvoiceCurrency(netPrice)}/year (was ${formatInvoiceCurrency(listPrice)})` + : `${formatInvoiceCurrency(listPrice)}/year`; + productSelect.innerHTML += `<option value="${product.lookup_key}">${escapeHtml(product.display_name)} - ${priceText}</option>`; + }); + + // Pre-select if specified + if (preSelectedKey) { + productSelect.value = preSelectedKey; + } + } catch (error) { + console.error('Error loading products:', error); + productSelect.innerHTML = '<option value="">Failed to load products</option>'; + } + } + + function calculateInvoiceDiscountedPrice(amountCents) { + if (currentOrg?.billing?.discount_percent) { + return Math.round(amountCents * (1 - currentOrg.billing.discount_percent / 100)); + } else if (currentOrg?.billing?.discount_amount_cents) { + return Math.max(0, amountCents - currentOrg.billing.discount_amount_cents); + } + return amountCents; + } + + function formatInvoiceCurrency(cents) { + return new Intl.NumberFormat('en-US', { + style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 + }).format(cents / 100); + } + + function closeInvoiceRequestModal() { + document.getElementById('invoiceRequestModal').style.display = 'none'; + selectedInvoiceProductData = null; + } + + function prepareDraftInvoice() { + // Collect form data + const companyName = document.getElementById('invoice-company').value; + const contactName = document.getElementById('invoice-contact').value; + const contactEmail = document.getElementById('invoice-email').value; + const lookupKey = document.getElementById('invoice-product').value; + const addressLine1 = document.getElementById('invoice-address1').value; + const addressLine2 = document.getElementById('invoice-address2').value; + const city = document.getElementById('invoice-city').value; + const state = document.getElementById('invoice-state').value; + const postalCode = document.getElementById('invoice-postal').value; + const country = document.getElementById('invoice-country').value; + + // Find selected product + const product = invoiceProducts.find(p => p.lookup_key === lookupKey); + if (!product) { + document.getElementById('invoiceFormError').textContent = 'Please select a product'; + document.getElementById('invoiceFormError').style.display = 'block'; + return; + } + + // Check for missing required fields + const missingFields = []; + if (!companyName) missingFields.push('Company Name'); + if (!contactName) missingFields.push('Contact Name'); + if (!contactEmail) missingFields.push('Billing Email'); + if (!addressLine1) missingFields.push('Address Line 1'); + if (!city) missingFields.push('City'); + if (!state) missingFields.push('State/Province'); + if (!postalCode) missingFields.push('Postal Code'); + if (!country) missingFields.push('Country'); + + // Store selected product data + selectedInvoiceProductData = { product, lookupKey }; + + // Calculate pricing + const hasDiscount = currentOrg?.billing?.discount_percent || currentOrg?.billing?.discount_amount_cents; + const listPrice = product.amount_cents || 0; + const netPrice = hasDiscount ? calculateInvoiceDiscountedPrice(listPrice) : listPrice; + + // Build discount info + let discountHtml = ''; + if (hasDiscount && listPrice !== netPrice) { + const discountText = currentOrg.billing.discount_percent + ? `${currentOrg.billing.discount_percent}% discount` + : `${formatInvoiceCurrency(currentOrg.billing.discount_amount_cents)} off`; + const savings = formatInvoiceCurrency(listPrice - netPrice); + discountHtml = ` + <div style="margin-top: 8px; padding: 8px; background: var(--color-success-50); border-radius: 4px;"> + <span style="color: var(--color-success-700); font-size: 13px;">${discountText} applied (saves ${savings})</span> + </div> + `; + } + + // Build address display + const addressParts = [addressLine1]; + if (addressLine2) addressParts.push(addressLine2); + addressParts.push(`${city}, ${state} ${postalCode}`); + addressParts.push(country); + const fullAddress = addressParts.filter(Boolean).join('<br>'); + + // Populate draft review content + document.getElementById('invoiceDraftReviewContent').innerHTML = ` + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;"> + <div> + <div style="font-size: 11px; color: var(--color-text-muted); text-transform: uppercase; margin-bottom: 4px;">Company</div> + <div style="font-weight: 600;">${escapeHtml(companyName) || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + </div> + <div> + <div style="font-size: 11px; color: var(--color-text-muted); text-transform: uppercase; margin-bottom: 4px;">Contact</div> + <div style="font-weight: 600;">${escapeHtml(contactName) || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + <div style="color: var(--color-text-secondary); font-size: 13px;">${escapeHtml(contactEmail) || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + </div> + </div> + + <hr style="margin: 16px 0; border: none; border-top: 1px solid var(--color-border);"> + + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;"> + <div> + <div style="font-size: 11px; color: var(--color-text-muted); text-transform: uppercase; margin-bottom: 4px;">Product</div> + <div style="font-weight: 600;">${escapeHtml(product.display_name)}</div> + </div> + <div> + <div style="font-size: 11px; color: var(--color-text-muted); text-transform: uppercase; margin-bottom: 4px;">Amount</div> + ${hasDiscount && listPrice !== netPrice + ? `<div><span style="text-decoration: line-through; color: var(--color-text-muted);">${formatInvoiceCurrency(listPrice)}</span> <span style="color: var(--color-success-600); font-weight: 600;">${formatInvoiceCurrency(netPrice)}/year</span></div>` + : `<div style="font-weight: 600;">${formatInvoiceCurrency(listPrice)}/year</div>` + } + ${discountHtml} + </div> + </div> + + <hr style="margin: 16px 0; border: none; border-top: 1px solid var(--color-border);"> + + <div> + <div style="font-size: 11px; color: var(--color-text-muted); text-transform: uppercase; margin-bottom: 4px;">Billing Address</div> + <div style="line-height: 1.5;">${fullAddress || '<em style="color: var(--color-error-500);">Not provided</em>'}</div> + </div> + `; + + // Show/hide missing fields warning + const missingFieldsDiv = document.getElementById('invoiceMissingFields'); + if (missingFields.length > 0) { + missingFieldsDiv.innerHTML = `<strong style="font-size: 13px;">Missing Information:</strong> ${missingFields.join(', ')}`; + missingFieldsDiv.style.display = 'block'; + } else { + missingFieldsDiv.style.display = 'none'; + } + + // Update UI state + document.getElementById('invoiceFormSection').style.display = 'none'; + document.getElementById('invoiceDraftReviewSection').style.display = 'block'; + document.getElementById('prepareDraftInvoiceBtn').style.display = 'none'; + document.getElementById('backToEditInvoiceBtn').style.display = 'inline-block'; + document.getElementById('sendInvoiceRequestBtn').style.display = missingFields.length === 0 ? 'inline-block' : 'none'; + } + + function backToEditInvoiceRequest() { + document.getElementById('invoiceDraftReviewSection').style.display = 'none'; + document.getElementById('invoiceFormSection').style.display = 'block'; + document.getElementById('prepareDraftInvoiceBtn').style.display = 'inline-block'; + document.getElementById('backToEditInvoiceBtn').style.display = 'none'; + document.getElementById('sendInvoiceRequestBtn').style.display = 'none'; + } + + async function submitInvoiceRequest(event) { + event.preventDefault(); + + const submitBtn = document.getElementById('sendInvoiceRequestBtn'); + const errorDiv = document.getElementById('invoiceFormError'); + + submitBtn.disabled = true; + submitBtn.textContent = 'Sending...'; + errorDiv.style.display = 'none'; + + const form = document.getElementById('invoiceRequestForm'); + const formData = new FormData(form); + + const requestData = { + companyName: formData.get('companyName'), + contactName: formData.get('contactName'), + contactEmail: formData.get('contactEmail'), + lookupKey: formData.get('lookupKey'), + billingAddress: { + line1: formData.get('line1'), + line2: formData.get('line2') || undefined, + city: formData.get('city'), + state: formData.get('state'), + postal_code: formData.get('postal_code'), + country: formData.get('country'), + }, + }; + + try { + const response = await fetch('/api/invoice-request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(requestData), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || 'Failed to send invoice'); + } + + // Show success state + document.getElementById('invoiceDraftReviewSection').style.display = 'none'; + document.getElementById('backToEditInvoiceBtn').style.display = 'none'; + document.getElementById('sendInvoiceRequestBtn').style.display = 'none'; + + document.getElementById('invoiceSuccessSection').innerHTML = ` + <div style="text-align: center; padding: 24px;"> + <div style="font-size: 48px; margin-bottom: 16px;">✅</div> + <h3 style="color: var(--color-success-600); margin-bottom: 8px;">Invoice Sent!</h3> + <p style="color: var(--color-text-secondary); margin-bottom: 16px;">We've sent an invoice to <strong>${escapeHtml(requestData.contactEmail)}</strong>. Please check your email for payment instructions.</p> + <p style="font-size: 14px; color: var(--color-text-muted);">The invoice is due within 30 days. You can pay online via the link in the email.</p> + </div> + `; + document.getElementById('invoiceSuccessSection').style.display = 'block'; + + } catch (error) { + errorDiv.textContent = error.message || 'Something went wrong. Please try again or contact finance@agenticadvertising.org'; + errorDiv.style.display = 'block'; + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Send Invoice'; + } + } </script> + + <!-- Invoice Request Modal (Two-Step Draft Process) --> + <div id="invoiceRequestModal" class="modal-overlay" style="display: none;"> + <div style="background: var(--color-bg-card); border-radius: 12px; max-width: 520px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-xl);"> + <div style="padding: 20px 24px; border-bottom: 1px solid var(--color-border); display: flex; justify-content: space-between; align-items: center;"> + <h2 style="margin: 0; font-size: 18px; color: var(--color-text-heading);">Request an Invoice</h2> + <button onclick="closeInvoiceRequestModal()" style="background: none; border: none; font-size: 24px; color: var(--color-text-muted); cursor: pointer; padding: 0; line-height: 1;">×</button> + </div> + <form id="invoiceRequestForm" onsubmit="submitInvoiceRequest(event)"> + <!-- Step 1: Form Section --> + <div id="invoiceFormSection" style="padding: 24px;"> + <div style="margin-bottom: 16px;"> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Company Name *</label> + <input type="text" id="invoice-company" name="companyName" required placeholder="Acme Corporation" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px;"> + <div> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Contact Name *</label> + <input type="text" id="invoice-contact" name="contactName" required placeholder="John Smith" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + <div> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Billing Email *</label> + <input type="email" id="invoice-email" name="contactEmail" required placeholder="billing@acme.com" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + </div> + + <div style="margin-bottom: 16px;"> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Product *</label> + <select id="invoice-product" name="lookupKey" required style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px; background: white;"> + <option value="">Select a product...</option> + </select> + </div> + + <div style="margin-bottom: 16px;"> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Billing Address Line 1 *</label> + <input type="text" id="invoice-address1" name="line1" required placeholder="123 Main Street" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + + <div style="margin-bottom: 16px;"> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Billing Address Line 2</label> + <input type="text" id="invoice-address2" name="line2" placeholder="Suite 100" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px;"> + <div> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">City *</label> + <input type="text" id="invoice-city" name="city" required placeholder="San Francisco" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + <div> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">State/Province *</label> + <input type="text" id="invoice-state" name="state" required placeholder="CA" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + </div> + + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px;"> + <div> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Postal Code *</label> + <input type="text" id="invoice-postal" name="postal_code" required placeholder="94105" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + <div> + <label style="display: block; font-size: 14px; font-weight: 500; color: var(--color-text); margin-bottom: 4px;">Country *</label> + <input type="text" id="invoice-country" name="country" required value="US" placeholder="US" style="width: 100%; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 8px; font-size: 14px;"> + </div> + </div> + + <div id="invoiceFormError" style="display: none; color: var(--color-error-600); font-size: 14px; padding: 12px; background: var(--color-error-50); border-radius: 8px;"></div> + </div> + + <!-- Step 2: Draft Review Section --> + <div id="invoiceDraftReviewSection" style="display: none; padding: 24px;"> + <div style="background: var(--color-info-50); border: 1px solid var(--color-info-200); border-radius: 8px; padding: 12px; margin-bottom: 16px;"> + <strong style="color: var(--color-info-700);">Review Invoice Draft</strong> + <p style="margin: 4px 0 0; font-size: 13px; color: var(--color-text-secondary);"> + Please review all details before sending. The invoice will be emailed to the billing contact. + </p> + </div> + + <div id="invoiceDraftReviewContent" style="background: var(--color-bg-subtle); border-radius: 8px; padding: 16px; margin-bottom: 16px;"> + <!-- Populated by JavaScript --> + </div> + + <div id="invoiceMissingFields" style="display: none; background: var(--color-warning-50); border: 1px solid var(--color-warning-200); border-radius: 8px; padding: 12px; color: var(--color-warning-700);"></div> + </div> + + <!-- Step 3: Success Section --> + <div id="invoiceSuccessSection" style="display: none;"> + <!-- Populated by JavaScript --> + </div> + + <div style="padding: 16px 24px; border-top: 1px solid var(--color-border); display: flex; gap: 12px; justify-content: flex-end;"> + <button type="button" class="btn btn-secondary" onclick="closeInvoiceRequestModal()">Cancel</button> + <button type="button" class="btn btn-secondary" id="backToEditInvoiceBtn" style="display: none;" onclick="backToEditInvoiceRequest()">Edit Details</button> + <button type="button" class="btn btn-primary" id="prepareDraftInvoiceBtn" onclick="prepareDraftInvoice()">Prepare Draft</button> + <button type="submit" class="btn btn-primary" id="sendInvoiceRequestBtn" style="display: none;">Send Invoice</button> + </div> + </form> + </div> + </div> </body> </html> diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 9590e1f78d..d9df7501ec 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -555,6 +555,7 @@ USE THIS when an admin says things like: - "Help [company] pay for membership" - "Give Acme a 20% discount and send them a payment link" - "Send a discounted link to the startup" +- "Let's issue an invoice to Boltive" (use draft_invoice first, then send_invoice to confirm) This tool will: 1. Find the company (or create it if it doesn't exist) @@ -563,9 +564,13 @@ This tool will: 4. Generate a direct Stripe payment link OR send an invoice For payment links: Returns a direct Stripe checkout URL you can share with the prospect. -For invoices: Sends an email from Stripe with a payment link - good for companies that need NET30/PO. -For discounts: Can create a new discount or auto-apply an existing org discount.`, - usage_hints: 'This is the primary tool for converting prospects to members. Use it whenever payment is discussed.', +For invoices: Use action="draft_invoice" FIRST to prepare and review, then action="send_invoice" to send. +For discounts: Can create a new discount or auto-apply an existing org discount. + +IMPORTANT: For invoices, ALWAYS use the two-step process: +1. First call with action="draft_invoice" to prepare and show the draft for admin review +2. Then call with action="send_invoice" after admin confirms (or makes changes)`, + usage_hints: 'This is the primary tool for converting prospects to members. Use it whenever payment is discussed. For invoices, use draft_invoice first.', input_schema: { type: 'object', properties: { @@ -591,8 +596,8 @@ For discounts: Can create a new discount or auto-apply an existing org discount. }, action: { type: 'string', - enum: ['payment_link', 'invoice', 'lookup_only'], - description: 'What to do: payment_link (default), invoice (sends email), or lookup_only (just show info)', + enum: ['payment_link', 'draft_invoice', 'send_invoice', 'lookup_only'], + description: 'What to do: payment_link (default), draft_invoice (prepare invoice for review), send_invoice (send after review), or lookup_only (just show info). For invoices, ALWAYS use draft_invoice first, then send_invoice to confirm.', }, lookup_key: { type: 'string', @@ -2897,8 +2902,120 @@ export function createAdminToolHandlers( } } - // Send invoice - if (action === 'invoice') { + // Draft invoice - prepare and show for review (TWO-STEP PROCESS: Step 1) + if (action === 'draft_invoice') { + if (!finalProduct) { + return response + `\n❌ No membership products available. Please check Stripe configuration.`; + } + + // Determine what discount would be applied + let appliedDiscount: string | undefined; + let finalAmount = finalProduct.amount_cents || 0; + + // Check if a new discount is being requested + if (discountPercent !== undefined || discountAmountDollars !== undefined) { + if (!discountReason) { + return response + `\n❌ Please provide a discount_reason when applying a discount.`; + } + appliedDiscount = discountPercent ? `${discountPercent}% off` : `$${discountAmountDollars} off`; + if (discountPercent) { + finalAmount = Math.round(finalAmount * (1 - discountPercent / 100)); + } else if (discountAmountDollars) { + finalAmount = Math.max(0, finalAmount - (discountAmountDollars * 100)); + } + } else if (useExistingDiscount && (org.discount_percent || org.discount_amount_cents)) { + // Use existing org discount + appliedDiscount = org.discount_percent + ? `${org.discount_percent}% off` + : `$${(org.discount_amount_cents || 0) / 100} off`; + if (org.discount_percent) { + finalAmount = Math.round(finalAmount * (1 - org.discount_percent / 100)); + } else if (org.discount_amount_cents) { + finalAmount = Math.max(0, finalAmount - org.discount_amount_cents); + } + } + + response += `### 📋 Draft Invoice for Review\n\n`; + response += `---\n\n`; + + // Company info + response += `**Company:** ${org.name}\n`; + if (org.revenue_tier) { + const tierLabels: Record<string, string> = { + 'under_1m': 'Under $1M', + '1m_5m': '$1M-$5M', + '5m_50m': '$5M-$50M', + '50m_250m': '$50M-$250M', + '250m_1b': '$250M-$1B', + '1b_plus': 'Over $1B' + }; + response += `**Revenue Tier:** ${tierLabels[org.revenue_tier] || org.revenue_tier}\n`; + } + + // Contact info + response += `\n**Contact:** ${org.prospect_contact_name || contactName || '_Not set_'}\n`; + response += `**Email:** ${emailToUse || '_Not set - required for invoice_'}\n`; + + // Billing address + response += `\n**Billing Address:**\n`; + if (billingAddress?.line1) { + response += `${billingAddress.line1}\n`; + if (billingAddress.line2) response += `${billingAddress.line2}\n`; + response += `${billingAddress.city || ''}, ${billingAddress.state || ''} ${billingAddress.postal_code || ''}\n`; + response += `${billingAddress.country || 'US'}\n`; + } else { + response += `_Not provided - required for invoice_\n`; + } + + // Product and pricing + response += `\n---\n\n`; + response += `**Product:** ${finalProduct.display_name}\n`; + if (finalProduct.amount_cents) { + const listPrice = finalProduct.amount_cents / 100; + const netPrice = finalAmount / 100; + if (appliedDiscount) { + response += `**List Price:** ~~$${listPrice.toLocaleString()}~~/year\n`; + response += `**Discount:** ${appliedDiscount}`; + if (org.discount_percent || org.discount_amount_cents) { + response += ` (existing org discount)`; + } + response += `\n`; + response += `**Invoice Amount:** **$${netPrice.toLocaleString()}**/year\n`; + } else { + response += `**Invoice Amount:** $${listPrice.toLocaleString()}/year\n`; + } + } + response += `**Payment Terms:** NET 30\n`; + + // What's missing + const missingFields: string[] = []; + if (!emailToUse) missingFields.push('contact_email'); + if (!billingAddress?.line1) missingFields.push('billing_address'); + + if (missingFields.length > 0) { + response += `\n---\n\n`; + response += `⚠️ **Missing required fields:** ${missingFields.join(', ')}\n`; + response += `\n_Please provide these fields to proceed._\n`; + } else { + response += `\n---\n\n`; + response += `✅ **Ready to send!**\n\n`; + response += `To send this invoice, confirm with the admin then call this tool again with:\n`; + response += `- \`action: "send_invoice"\`\n`; + response += `- Same company_name and all other parameters\n`; + response += `\nOr if changes are needed, ask the admin what to modify.\n`; + } + + logger.info( + { orgId: org.workos_organization_id, orgName: org.name, product: finalProduct.lookup_key, discount: appliedDiscount }, + 'Addie prepared draft invoice' + ); + + return response; + } + + // Send invoice - actually send after review (TWO-STEP PROCESS: Step 2) + // Also support legacy 'invoice' action for backward compatibility + if (action === 'send_invoice' || action === 'invoice') { if (!emailToUse) { return response + `\n❌ Cannot send invoice without an email address. Please provide contact_email.`; } @@ -2998,6 +3115,10 @@ export function createAdminToolHandlers( discountedAmount = originalAmount * (1 - discountPercent / 100); } else if (discountAmountDollars) { discountedAmount = originalAmount - discountAmountDollars; + } else if (org.discount_percent) { + discountedAmount = originalAmount * (1 - org.discount_percent / 100); + } else if (org.discount_amount_cents) { + discountedAmount = originalAmount - (org.discount_amount_cents / 100); } response += ` → **$${discountedAmount.toLocaleString()}** (${appliedDiscount} applied)`; } @@ -3022,7 +3143,7 @@ export function createAdminToolHandlers( } } - return response + `\n❌ Unknown action: ${action}. Use "payment_link", "invoice", or "lookup_only".`; + return response + `\n❌ Unknown action: ${action}. Use "payment_link", "draft_invoice", "send_invoice", or "lookup_only".`; }); // Search Lusha for prospects diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 5a2b3313c0..b19f7689c3 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -590,6 +590,7 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } { contact_name, contact_email, billing_address, + coupon_id, } = req.body; if (!lookup_key || !company_name || !contact_name || !contact_email || !billing_address) { @@ -609,7 +610,7 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } { const pool = getPool(); const orgResult = await pool.query( - `SELECT workos_organization_id, name FROM organizations WHERE workos_organization_id = $1`, + `SELECT workos_organization_id, name, stripe_coupon_id FROM organizations WHERE workos_organization_id = $1`, [orgId] ); @@ -619,6 +620,9 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } { const org = orgResult.rows[0]; + // Use explicit coupon_id from request, or fall back to org's saved coupon + const effectiveCouponId = coupon_id || org.stripe_coupon_id; + const result = await createAndSendInvoice({ lookupKey: lookup_key, companyName: company_name, @@ -633,6 +637,7 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } { country: billing_address.country, }, workosOrganizationId: orgId, + couponId: effectiveCouponId, }); if (!result) { diff --git a/server/src/routes/billing-public.ts b/server/src/routes/billing-public.ts index d7b96b00b6..713f7e5c4d 100644 --- a/server/src/routes/billing-public.ts +++ b/server/src/routes/billing-public.ts @@ -375,6 +375,10 @@ export function createPublicBillingRouter(): Router { workosOrganizationId: orgId, workosUserId: user.id, isPersonalWorkspace: org.is_personal || false, + // Apply org's stored coupon if available + couponId: org.stripe_coupon_id || undefined, + // Only use promotion code if no coupon ID (coupon takes precedence) + promotionCode: !org.stripe_coupon_id ? (org.stripe_promotion_code || undefined) : undefined, }; const result = await createCheckoutSession(checkoutData); @@ -607,6 +611,10 @@ export function createPublicBillingRouter(): Router { // Enrichment-based suggestions for prefilling the profile modal suggested_company_type: suggestedCompanyType, suggested_revenue_tier: suggestedRevenueTier, + // Discount info for display (if any) + discount_percent: org.discount_percent || null, + discount_amount_cents: org.discount_amount_cents || null, + stripe_promotion_code: org.stripe_promotion_code || null, }); } catch (error) { logger.error({ err: error }, "Get billing info error:"); From fa685885c0ea94f599de516b704e64077d69f583 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:13:23 -0500 Subject: [PATCH 21/77] fix: display Slack profile name for chapter leaders without WorkOS accounts (#696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaders added via Slack ID that haven't linked their WorkOS account were showing raw Slack user IDs (e.g., U09BEKNJ3GB) instead of their names. The getLeaders and getLeadersBatch queries now include slack_user_mappings as an additional name source, falling back to slack_real_name or slack_display_name before showing the raw user ID. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/fix-slack-leader-display.md | 12 ++++++++++++ server/src/db/working-group-db.ts | 13 ++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-slack-leader-display.md 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/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index c777a3fc67..8623a1ca5e 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -590,7 +590,8 @@ export class WorkingGroupDatabase { // 1. working_group_memberships (if they're a member with cached name) // 2. users table (canonical user data synced from WorkOS) // 3. organization_memberships (older sync table) - // 4. Falls back to user_id if no name found + // 4. slack_user_mappings (Slack profile name for unmapped Slack users) + // 5. Falls back to user_id if no name found // // canonical_user_id is resolved at read time via slack_user_mappings // to handle cases where leaders were added via Slack ID before linking WorkOS @@ -604,12 +605,17 @@ export class WorkingGroupDatabase { NULLIF(TRIM(CONCAT(om.first_name, ' ', om.last_name)), ''), u.email, om.email, + NULLIF(slack_profile.slack_real_name, ''), + NULLIF(slack_profile.slack_display_name, ''), wgl.user_id ) AS name, COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, wgl.created_at FROM working_group_leaders wgl + -- sm: resolves Slack ID -> WorkOS ID (only for linked users) LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + -- slack_profile: gets Slack profile name (for all Slack users, including unlinked) + LEFT JOIN slack_user_mappings slack_profile ON wgl.user_id = slack_profile.slack_user_id LEFT JOIN working_group_memberships wgm ON COALESCE(sm.workos_user_id, wgl.user_id) = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id LEFT JOIN users u ON COALESCE(sm.workos_user_id, wgl.user_id) = u.workos_user_id LEFT JOIN organizations user_org ON u.primary_organization_id = user_org.workos_organization_id @@ -650,12 +656,17 @@ export class WorkingGroupDatabase { NULLIF(TRIM(CONCAT(om.first_name, ' ', om.last_name)), ''), u.email, om.email, + NULLIF(slack_profile.slack_real_name, ''), + NULLIF(slack_profile.slack_display_name, ''), wgl.user_id ) AS name, COALESCE(wgm.user_org_name, user_org.name, org.name) AS org_name, wgl.created_at FROM working_group_leaders wgl + -- sm: resolves Slack ID -> WorkOS ID (only for linked users) LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + -- slack_profile: gets Slack profile name (for all Slack users, including unlinked) + LEFT JOIN slack_user_mappings slack_profile ON wgl.user_id = slack_profile.slack_user_id LEFT JOIN working_group_memberships wgm ON COALESCE(sm.workos_user_id, wgl.user_id) = wgm.workos_user_id AND wgm.working_group_id = wgl.working_group_id LEFT JOIN users u ON COALESCE(sm.workos_user_id, wgl.user_id) = u.workos_user_id LEFT JOIN organizations user_org ON u.primary_organization_id = user_org.workos_organization_id From 682258660039eb9a9ecd0ffb0968e8dbd025318e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:22:16 -0500 Subject: [PATCH 22/77] feat: add full org page capabilities to account detail page (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port all missing features from admin-org-detail.html to admin-account-detail.html: - Payment Link modal with product selection and discount support - Invoice modal with product selection and billing address form - Revenue Tier selector in Edit Account modal - Parent Organization selector in Edit Account modal - Addie Research section (loaded asynchronously) - Member Insights section (loaded asynchronously) - Domain actions: verify, set primary, remove Security improvements: - Use event delegation with data attributes instead of inline onclick handlers to prevent XSS vulnerabilities - Add 401 authentication handling to domain action functions - Escape user-provided values in confirmation dialogs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/forty-symbols-kick.md | 2 + server/public/admin-account-detail.html | 879 +++++++++++++++++++++++- 2 files changed, 850 insertions(+), 31 deletions(-) create mode 100644 .changeset/forty-symbols-kick.md 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/server/public/admin-account-detail.html b/server/public/admin-account-detail.html index d11e36998a..212a8a1d86 100644 --- a/server/public/admin-account-detail.html +++ b/server/public/admin-account-detail.html @@ -788,6 +788,26 @@ <h2 class="collapsible" onclick="toggleSection(this)" style="margin-bottom: 0;"> </div> </div> + <!-- Addie Research --> + <div class="card" id="addieResearchCard" style="display: none;"> + <h2 class="collapsible" onclick="toggleSection(this)">Addie Research</h2> + <div class="collapsible-content" id="addieResearchContent"> + <div id="addieResearchList" class="empty-state"> + No research available + </div> + </div> + </div> + + <!-- Member Insights --> + <div class="card" id="memberInsightsCard" style="display: none;"> + <h2 class="collapsible" onclick="toggleSection(this)">Member Insights</h2> + <div class="collapsible-content" id="memberInsightsContent"> + <div id="memberInsightsList" class="empty-state"> + No insights yet + </div> + </div> + </div> + <!-- Members --> <div class="card"> <div style="display: flex; justify-content: space-between; align-items: center;"> @@ -948,9 +968,17 @@ <h2>Edit Account</h2> <form id="editForm" onsubmit="saveAccountChanges(event)"> <div class="form-group"> <label>Organization Name</label> - <input type="text" id="editName" disabled> - <div class="form-hint">Name cannot be changed here</div> + <input type="text" id="editName"> + </div> + + <div class="form-group"> + <label>Parent Organization</label> + <select id="editParentOrg"> + <option value="">None (independent)</option> + </select> + <div class="form-hint">If this is a subsidiary or division, select the parent company.</div> </div> + <div class="form-row"> <div class="form-group"> <label>Company Type</label> @@ -977,6 +1005,20 @@ <h2>Edit Account</h2> </select> </div> </div> + + <div class="form-group"> + <label>Revenue Tier</label> + <select id="editRevenueTier"> + <option value="">Unknown</option> + <option value="under_1m">Under $1M (Startup)</option> + <option value="1m_5m">$1M - $5M</option> + <option value="5m_50m">$5M - $50M</option> + <option value="50m_250m">$50M - $250M</option> + <option value="250m_1b">$250M - $1B</option> + <option value="1b_plus">Over $1B (Enterprise)</option> + </select> + <div class="form-hint">Used to determine membership pricing tier.</div> + </div> <h3 style="margin-top: var(--space-5); margin-bottom: var(--space-4); font-size: var(--text-base);">Contact</h3> <div class="form-row"> <div class="form-group"> @@ -1089,29 +1131,151 @@ <h2 id="convertAccountTitle">Convert Account Type</h2> </div> </div> - <!-- Payment Link Modal (placeholder) --> + <!-- Payment Link Modal --> <div id="paymentLinkModal" class="modal"> - <div class="modal-content"> + <div class="modal-content" style="max-width: 500px;"> <div class="modal-header"> <h2>Generate Payment Link</h2> <button class="modal-close" onclick="closeModal('paymentLinkModal')">×</button> </div> - <div id="paymentLinkContent"> - <!-- Will be populated by JS --> + + <p id="paymentLinkOrgName" style="margin-bottom: var(--space-4); color: var(--color-text-secondary);"></p> + + <!-- Discount notice --> + <div id="paymentLinkDiscountNotice" style="display: none; margin-bottom: var(--space-4); padding: var(--space-3); background: var(--color-success-50); border-radius: var(--radius-sm);"> + <div style="font-size: var(--text-sm);"> + <strong style="color: var(--color-success-700);">Discount will be applied:</strong> + <span id="paymentLinkDiscountText"></span> + </div> + <div style="margin-top: var(--space-2);"> + <label style="font-size: var(--text-sm);"> + <input type="checkbox" id="applyDiscountToPaymentLink" checked> + Include discount in payment link + </label> + </div> + </div> + + <div id="paymentLinkProducts" style="margin-bottom: var(--space-4);"> + Loading products... + </div> + + <div id="paymentLinkResult" style="display: none;"> + <div style="background: var(--color-success-50); border: 1px solid var(--color-success-200); border-radius: var(--radius-md); padding: var(--space-4); margin-bottom: var(--space-4);"> + <strong style="color: var(--color-success-700);">Payment Link Generated!</strong> + <p style="margin: var(--space-2) 0 0; font-size: var(--text-sm); color: var(--color-text-secondary);"> + Copy this link and send it to the prospect: + </p> + </div> + <div style="display: flex; gap: var(--space-2);"> + <input type="text" id="paymentLinkUrl" readonly style="flex: 1; padding: var(--space-2); font-size: var(--text-sm); border: 1px solid var(--color-border); border-radius: var(--radius-md);"> + <button class="btn btn-primary btn-sm" onclick="copyPaymentLink()">Copy</button> + </div> + </div> + + <div class="modal-actions"> + <button type="button" class="btn btn-secondary" onclick="closeModal('paymentLinkModal')">Close</button> </div> </div> </div> - <!-- Invoice Modal (placeholder) --> + <!-- Invoice Modal --> <div id="invoiceModal" class="modal"> - <div class="modal-content"> + <div class="modal-content" style="max-width: 600px;"> <div class="modal-header"> - <h2>Send Invoice</h2> + <h2>Generate Invoice</h2> <button class="modal-close" onclick="closeModal('invoiceModal')">×</button> </div> - <div id="invoiceContent"> - <!-- Will be populated by JS --> - </div> + + <p id="invoiceOrgName" style="margin-bottom: var(--space-4); color: var(--color-text-secondary);"></p> + + <form id="invoiceForm" onsubmit="submitInvoice(event)"> + <!-- Product Selection --> + <div id="invoiceProductSection"> + <div class="form-group"> + <label>Select Product</label> + <div id="invoiceProducts" style="margin-bottom: var(--space-4);"> + Loading products... + </div> + </div> + </div> + + <!-- Billing Details (shown after product selection) --> + <div id="invoiceBillingSection" style="display: none;"> + <div class="form-group"> + <label>Selected Product</label> + <div id="selectedProductDisplay" style="padding: var(--space-2); background: var(--color-gray-50); border-radius: var(--radius-md); margin-bottom: var(--space-3);"></div> + </div> + + <div class="form-row"> + <div class="form-group"> + <label>Contact Name *</label> + <input type="text" id="invoiceContactName" required> + </div> + <div class="form-group"> + <label>Contact Email *</label> + <input type="email" id="invoiceContactEmail" required> + </div> + </div> + + <div class="form-group"> + <label>Company Name *</label> + <input type="text" id="invoiceCompanyName" required> + </div> + + <h4 style="margin-top: var(--space-4); margin-bottom: var(--space-3); font-size: var(--text-sm); color: var(--color-text-heading);">Billing Address</h4> + + <div class="form-group"> + <label>Street Address *</label> + <input type="text" id="invoiceAddressLine1" required> + </div> + + <div class="form-group"> + <label>Address Line 2</label> + <input type="text" id="invoiceAddressLine2"> + </div> + + <div class="form-row"> + <div class="form-group"> + <label>City *</label> + <input type="text" id="invoiceCity" required> + </div> + <div class="form-group"> + <label>State/Province *</label> + <input type="text" id="invoiceState" required> + </div> + </div> + + <div class="form-row"> + <div class="form-group"> + <label>Postal Code *</label> + <input type="text" id="invoicePostalCode" required> + </div> + <div class="form-group"> + <label>Country *</label> + <input type="text" id="invoiceCountry" value="US" required> + </div> + </div> + </div> + + <!-- Success Message --> + <div id="invoiceResult" style="display: none;"> + <div style="background: var(--color-success-50); border: 1px solid var(--color-success-200); border-radius: var(--radius-md); padding: var(--space-4); margin-bottom: var(--space-4);"> + <strong style="color: var(--color-success-700);">Invoice Sent!</strong> + <p style="margin: var(--space-2) 0 0; font-size: var(--text-sm); color: var(--color-text-secondary);"> + The invoice has been emailed to the contact. You can also share this link: + </p> + </div> + <div style="display: flex; gap: var(--space-2);"> + <input type="text" id="invoiceUrl" readonly style="flex: 1; padding: var(--space-2); font-size: var(--text-sm); border: 1px solid var(--color-border); border-radius: var(--radius-md);"> + <button type="button" class="btn btn-primary btn-sm" onclick="copyInvoiceLink()">Copy</button> + </div> + </div> + + <div class="modal-actions"> + <button type="button" class="btn btn-secondary" onclick="closeModal('invoiceModal')">Close</button> + <button type="submit" class="btn btn-primary" id="sendInvoiceBtn" style="display: none;">Send Invoice</button> + </div> + </form> </div> </div> @@ -1214,6 +1378,10 @@ <h2 id="userContextModalTitle">Member Context</h2> renderAccount(); document.getElementById('loading').style.display = 'none'; document.getElementById('content').style.display = 'block'; + + // Load additional data asynchronously + loadAddieResearch(); + loadMemberInsights(); } catch (error) { console.error('Error loading account:', error); document.getElementById('loading').textContent = 'Error loading account'; @@ -1376,17 +1544,7 @@ <h2 id="userContextModalTitle">Member Context</h2> // Domains if (a.domains && a.domains.length > 0) { - let domainsHtml = ''; - for (const d of a.domains) { - domainsHtml += `<div class="domain-item"> - <span>${escapeHtml(d.domain)}</span> - <div class="domain-badges"> - ${d.is_primary ? '<span class="domain-badge primary">Primary</span>' : ''} - ${d.verified ? '<span class="domain-badge verified">Verified</span>' : ''} - </div> - </div>`; - } - document.getElementById('domainsList').innerHTML = domainsHtml; + renderDomains(a.domains); } // Domain Health @@ -1950,7 +2108,7 @@ <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--spa } // Edit account - function openEditModal() { + async function openEditModal() { document.getElementById('editName').value = accountData.name; document.getElementById('editCompanyType').value = accountData.company_type || ''; document.getElementById('editSource').value = accountData.source || ''; @@ -1959,10 +2117,45 @@ <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--spa document.getElementById('editContactTitle').value = accountData.contact_title || ''; document.getElementById('editDisqualified').checked = accountData.is_disqualified; document.getElementById('editDisqualifyReason').value = accountData.disqualification_reason || ''; + document.getElementById('editRevenueTier').value = accountData.revenue_tier || ''; toggleDisqualifyReason(); + + // Load and populate parent org options + await loadParentOrgOptions(); + openModal('editModal'); } + async function loadParentOrgOptions() { + const select = document.getElementById('editParentOrg'); + // Keep the "None" option + select.innerHTML = '<option value="">None (independent)</option>'; + + try { + const response = await fetch('/api/admin/prospects?limit=500'); + if (!response.ok) return; + + const data = await response.json(); + const orgs = data.prospects || []; + + // Filter out current org and sort by name + orgs + .filter(org => org.workos_organization_id !== accountId) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')) + .forEach(org => { + const option = document.createElement('option'); + option.value = org.workos_organization_id; + option.textContent = org.name; + if (org.workos_organization_id === accountData.parent_organization_id) { + option.selected = true; + } + select.appendChild(option); + }); + } catch (error) { + console.error('Error loading parent org options:', error); + } + } + function toggleDisqualifyReason() { document.getElementById('disqualifyReasonGroup').style.display = document.getElementById('editDisqualified').checked ? 'block' : 'none'; @@ -1972,6 +2165,7 @@ <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--spa event.preventDefault(); const body = { + name: document.getElementById('editName').value || null, company_type: document.getElementById('editCompanyType').value || null, prospect_source: document.getElementById('editSource').value || null, prospect_contact_name: document.getElementById('editContactName').value || null, @@ -1979,7 +2173,9 @@ <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--spa prospect_contact_title: document.getElementById('editContactTitle').value || null, prospect_status: document.getElementById('editDisqualified').checked ? 'disqualified' : null, disqualification_reason: document.getElementById('editDisqualified').checked ? - document.getElementById('editDisqualifyReason').value : null + document.getElementById('editDisqualifyReason').value : null, + revenue_tier: document.getElementById('editRevenueTier').value || null, + parent_organization_id: document.getElementById('editParentOrg').value || null }; try { @@ -2021,6 +2217,164 @@ <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--spa } } + function renderDomains(domains) { + const container = document.getElementById('domainsList'); + + if (!domains || domains.length === 0) { + container.innerHTML = '<div class="empty-state">No domains linked</div>'; + return; + } + + container.innerHTML = `<ul class="domains-list" style="list-style: none; padding: 0; margin: 0;"> + ${domains.map(d => { + const escapedDomain = escapeHtml(d.domain); + const badges = []; + if (d.is_primary) badges.push('<span class="domain-badge primary">Primary</span>'); + if (d.verified) badges.push('<span class="domain-badge verified">Verified</span>'); + badges.push(`<span class="domain-badge ${escapeHtml(d.source || 'manual')}">${d.source === 'workos' ? 'WorkOS' : 'Manual'}</span>`); + + return ` + <li class="domain-item" data-domain="${escapedDomain}" style="display: flex; justify-content: space-between; align-items: center; padding: var(--space-2) 0; border-bottom: 1px solid var(--color-gray-100);"> + <div class="domain-info"> + <span class="domain-name" style="font-weight: var(--font-medium);">${escapedDomain}</span> + <span style="margin-left: var(--space-2);">${badges.join(' ')}</span> + </div> + <div class="domain-actions" style="display: flex; gap: var(--space-2);"> + ${!d.verified ? `<button class="btn btn-sm btn-primary" data-action="verify">Verify</button>` : ''} + ${!d.is_primary ? `<button class="btn btn-sm btn-secondary" data-action="set-primary">Set Primary</button>` : ''} + <button class="btn btn-sm btn-secondary" data-action="remove" style="color: var(--color-error-600);" title="Remove domain">×</button> + </div> + </li> + `; + }).join('')} + </ul>`; + + // Remove last border + const lastItem = container.querySelector('li:last-child'); + if (lastItem) { + lastItem.style.borderBottom = 'none'; + } + + // Event delegation for domain actions + container.onclick = handleDomainAction; + } + + function handleDomainAction(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const domainItem = btn.closest('[data-domain]'); + if (!domainItem) return; + + const domain = domainItem.dataset.domain; + const action = btn.dataset.action; + + if (action === 'verify') { + verifyDomain(domain, btn); + } else if (action === 'set-primary') { + setDomainPrimary(domain, btn); + } else if (action === 'remove') { + removeDomain(domain, btn); + } + } + + async function verifyDomain(domain, btn) { + const originalText = btn ? btn.textContent : ''; + if (btn) { + btn.disabled = true; + btn.textContent = 'Verifying...'; + } + + try { + const response = await fetch(`/api/admin/organizations/${accountId}/domains`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain: domain.toLowerCase() }) + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminNav?.redirectToLogin?.(); + return; + } + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to verify domain'); + } + + location.reload(); + } catch (error) { + console.error('Error verifying domain:', error); + alert('Error: ' + error.message); + if (btn) { + btn.disabled = false; + btn.textContent = originalText; + } + } + } + + async function setDomainPrimary(domain, btn) { + const originalText = btn ? btn.textContent : ''; + if (btn) { + btn.disabled = true; + btn.textContent = 'Setting...'; + } + + try { + const response = await fetch(`/api/admin/organizations/${accountId}/domains/${encodeURIComponent(domain)}/primary`, { + method: 'PUT' + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminNav?.redirectToLogin?.(); + return; + } + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to set primary domain'); + } + + location.reload(); + } catch (error) { + console.error('Error setting primary domain:', error); + alert('Error: ' + error.message); + if (btn) { + btn.disabled = false; + btn.textContent = originalText; + } + } + } + + async function removeDomain(domain, btn) { + if (!confirm(`Remove domain "${escapeHtml(domain)}" from this organization?`)) return; + + if (btn) { + btn.disabled = true; + } + + try { + const response = await fetch(`/api/admin/organizations/${accountId}/domains/${encodeURIComponent(domain)}`, { + method: 'DELETE' + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminNav?.redirectToLogin?.(); + return; + } + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to remove domain'); + } + + location.reload(); + } catch (error) { + console.error('Error removing domain:', error); + alert('Error: ' + error.message); + if (btn) { + btn.disabled = false; + } + } + } + // Enrichment refresh async function refreshEnrichment() { try { @@ -2032,16 +2386,479 @@ <h3 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--spa } } - // Payment link (placeholder) + // ========================================================================= + // Addie Research + // ========================================================================= + + async function loadAddieResearch() { + const card = document.getElementById('addieResearchCard'); + const container = document.getElementById('addieResearchList'); + + try { + const response = await fetch(`/api/admin/organizations/${accountId}/addie-research`); + + if (!response.ok) { + if (response.status === 404) { + card.style.display = 'none'; + return; + } + throw new Error('Failed to load Addie research'); + } + + const data = await response.json(); + + if (!data.interactions || data.interactions.length === 0) { + card.style.display = 'none'; + return; + } + + card.style.display = 'block'; + container.innerHTML = data.interactions.map(interaction => { + const date = new Date(interaction.created_at); + return ` + <div class="research-item" style="padding: var(--space-3) 0; border-bottom: var(--border-1) solid var(--color-gray-100);"> + <div style="font-size: var(--text-xs); color: var(--color-text-muted); margin-bottom: var(--space-1);"> + ${date.toLocaleDateString()} ${date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + ${interaction.user_name ? ` • Conversation with ${escapeHtml(interaction.user_name)}` : ''} + </div> + <div style="font-size: var(--text-sm);"> + ${escapeHtml(interaction.summary || interaction.output_text?.substring(0, 200) || 'No summary')} + ${interaction.output_text?.length > 200 ? '...' : ''} + </div> + ${interaction.tools_used?.length > 0 ? ` + <div style="font-size: var(--text-xs); color: var(--color-text-muted); margin-top: var(--space-1);"> + Tools: ${interaction.tools_used.join(', ')} + </div> + ` : ''} + </div> + `; + }).join(''); + + // Remove last border + const lastItem = container.querySelector('.research-item:last-child'); + if (lastItem) { + lastItem.style.borderBottom = 'none'; + } + + } catch (error) { + console.error('Error loading Addie research:', error); + card.style.display = 'none'; + } + } + + // ========================================================================= + // Member Insights + // ========================================================================= + + async function loadMemberInsights() { + const card = document.getElementById('memberInsightsCard'); + const container = document.getElementById('memberInsightsList'); + + try { + const response = await fetch(`/api/admin/organizations/${accountId}/member-insights`); + + if (!response.ok) { + if (response.status === 404) { + card.style.display = 'none'; + return; + } + throw new Error('Failed to load member insights'); + } + + const data = await response.json(); + + if (!data.insights || data.insights.length === 0) { + card.style.display = 'none'; + return; + } + + card.style.display = 'block'; + + // Group insights by member + const byMember = {}; + data.insights.forEach(insight => { + const key = insight.member_name || insight.slack_user_id || 'Unknown'; + if (!byMember[key]) { + byMember[key] = []; + } + byMember[key].push(insight); + }); + + container.innerHTML = Object.entries(byMember).map(([member, insights]) => ` + <div style="margin-bottom: var(--space-4);"> + <div style="font-weight: var(--font-semibold); margin-bottom: var(--space-2);">${escapeHtml(member)}</div> + ${insights.map(insight => ` + <div style="display: flex; gap: var(--space-2); padding: var(--space-1) 0; font-size: var(--text-sm);"> + <span style="background: var(--color-primary-100); color: var(--color-primary-700); padding: var(--space-0.5) var(--space-1.5); border-radius: var(--radius-sm); font-size: var(--text-xs); font-weight: var(--font-medium);"> + ${escapeHtml(insight.insight_type)} + </span> + <span>${escapeHtml(insight.value)}</span> + ${insight.confidence ? ` + <span style="font-size: var(--text-xs); color: var(--color-text-muted);"> + (${insight.confidence}) + </span> + ` : ''} + </div> + `).join('')} + </div> + `).join(''); + + } catch (error) { + console.error('Error loading member insights:', error); + card.style.display = 'none'; + } + } + + // ========================================================================= + // Payment Link Functions + // ========================================================================= + function openPaymentLinkModal() { - // Redirect to old page for now - window.location.href = `/admin/organizations/${accountId}`; + document.getElementById('paymentLinkOrgName').textContent = `For: ${accountData?.name || 'Unknown'}`; + document.getElementById('paymentLinkProducts').innerHTML = 'Loading products...'; + document.getElementById('paymentLinkProducts').style.display = 'block'; + document.getElementById('paymentLinkResult').style.display = 'none'; + + // Show discount notice if org has a discount + const discountNotice = document.getElementById('paymentLinkDiscountNotice'); + if (accountData.discount_percent || accountData.discount_amount_cents) { + discountNotice.style.display = 'block'; + let discountText = ''; + if (accountData.discount_percent) { + discountText = `${accountData.discount_percent}% off`; + } else { + discountText = `$${(accountData.discount_amount_cents / 100).toFixed(0)} off`; + } + if (accountData.stripe_promotion_code) { + discountText += ` (code: ${accountData.stripe_promotion_code})`; + } + document.getElementById('paymentLinkDiscountText').textContent = discountText; + document.getElementById('applyDiscountToPaymentLink').checked = true; + } else { + discountNotice.style.display = 'none'; + } + + openModal('paymentLinkModal'); + loadPaymentLinkProducts(); } - // Invoice (placeholder) + async function loadPaymentLinkProducts() { + try { + const response = await fetch(`/api/admin/prospects/${accountId}/payment-link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + if (!response.ok) { + throw new Error('Failed to load products'); + } + + const data = await response.json(); + + if (data.needs_selection && data.products) { + paymentLinkProductsData = data.products; + renderPaymentLinkProducts(data.products); + } + } catch (error) { + console.error('Error loading products:', error); + document.getElementById('paymentLinkProducts').innerHTML = + '<p style="color: var(--color-error-600);">Failed to load products. Please try again.</p>'; + } + } + + let paymentLinkProductsData = []; + + function renderPaymentLinkProducts(products) { + const container = document.getElementById('paymentLinkProducts'); + + if (products.length === 0) { + container.innerHTML = '<p style="color: var(--color-text-secondary);">No products available for this account type.</p>'; + return; + } + + container.innerHTML = products.map((product, index) => { + const price = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(product.amount_cents / 100); + + const displayName = escapeHtml(product.display_name); + const tiers = product.revenue_tiers?.length > 0 + ? `<span style="font-size: var(--text-xs); color: var(--color-text-muted);">(${product.revenue_tiers.map(t => escapeHtml(t)).join(', ')})</span>` + : ''; + + return ` + <button class="product-select-btn" data-product-index="${index}" style=" + display: block; + width: 100%; + text-align: left; + padding: var(--space-3); + margin-bottom: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + cursor: pointer; + transition: var(--transition-all); + " onmouseover="this.style.borderColor='var(--color-brand)'" onmouseout="this.style.borderColor='var(--color-border)'"> + <strong>${displayName}</strong> ${tiers} + <br> + <span style="color: var(--color-brand); font-weight: var(--font-semibold);">${price}/year</span> + </button> + `; + }).join(''); + + // Event delegation for product selection + container.onclick = function(e) { + const btn = e.target.closest('[data-product-index]'); + if (!btn) return; + const index = parseInt(btn.dataset.productIndex, 10); + const product = paymentLinkProductsData[index]; + if (product) { + generatePaymentLink(product.lookup_key); + } + }; + } + + async function generatePaymentLink(lookupKey) { + const container = document.getElementById('paymentLinkProducts'); + container.innerHTML = '<p>Generating payment link...</p>'; + + try { + const applyDiscount = document.getElementById('applyDiscountToPaymentLink')?.checked; + const body = { lookup_key: lookupKey }; + + if (applyDiscount && accountData.stripe_coupon_id) { + body.coupon_id = accountData.stripe_coupon_id; + } else if (applyDiscount && accountData.stripe_promotion_code) { + body.promotion_code = accountData.stripe_promotion_code; + } + + const response = await fetch(`/api/admin/prospects/${accountId}/payment-link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to generate payment link'); + } + + if (data.success && data.payment_url) { + container.style.display = 'none'; + document.getElementById('paymentLinkDiscountNotice').style.display = 'none'; + document.getElementById('paymentLinkUrl').value = data.payment_url; + document.getElementById('paymentLinkResult').style.display = 'block'; + } else { + throw new Error(data.message || 'Unknown error'); + } + } catch (error) { + console.error('Error generating payment link:', error); + container.innerHTML = `<p style="color: var(--color-error-600);">Error: ${escapeHtml(error.message)}</p>`; + } + } + + function copyPaymentLink() { + const input = document.getElementById('paymentLinkUrl'); + input.select(); + navigator.clipboard.writeText(input.value).then(() => { + showToast('Payment link copied to clipboard!'); + }).catch(() => { + document.execCommand('copy'); + showToast('Payment link copied to clipboard!'); + }); + } + + // ========================================================================= + // Invoice Functions + // ========================================================================= + + let selectedInvoiceProduct = null; + let invoiceProductsData = []; + function openInvoiceModal() { - // Redirect to old page for now - window.location.href = `/admin/organizations/${accountId}`; + selectedInvoiceProduct = null; + + document.getElementById('invoiceOrgName').textContent = `For: ${accountData?.name || 'Unknown'}`; + document.getElementById('invoiceProducts').innerHTML = 'Loading products...'; + document.getElementById('invoiceProductSection').style.display = 'block'; + document.getElementById('invoiceBillingSection').style.display = 'none'; + document.getElementById('invoiceResult').style.display = 'none'; + document.getElementById('sendInvoiceBtn').style.display = 'none'; + document.getElementById('invoiceForm').reset(); + + // Pre-fill from account data + if (accountData) { + document.getElementById('invoiceContactName').value = accountData.prospect_contact_name || ''; + document.getElementById('invoiceContactEmail').value = accountData.prospect_contact_email || ''; + document.getElementById('invoiceCompanyName').value = accountData.name || ''; + } + + openModal('invoiceModal'); + loadInvoiceProducts(); + } + + async function loadInvoiceProducts() { + try { + const response = await fetch('/api/admin/products'); + if (!response.ok) throw new Error('Failed to load products'); + + const data = await response.json(); + invoiceProductsData = data.products.filter(p => p.is_invoiceable); + + renderInvoiceProducts(invoiceProductsData); + } catch (error) { + console.error('Error loading products:', error); + document.getElementById('invoiceProducts').innerHTML = + '<p style="color: var(--color-error-600);">Failed to load products. Please try again.</p>'; + } + } + + function renderInvoiceProducts(products) { + const container = document.getElementById('invoiceProducts'); + + if (products.length === 0) { + container.innerHTML = '<p style="color: var(--color-text-secondary);">No invoiceable products available.</p>'; + return; + } + + container.innerHTML = products.map((product, index) => { + const price = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(product.amount_cents / 100); + + const interval = product.billing_interval ? `/${product.billing_interval}` : ''; + const displayName = escapeHtml(product.display_name || product.product_name); + const description = product.description ? escapeHtml(product.description) : ''; + + return ` + <button type="button" class="product-select-btn" data-product-index="${index}" style=" + display: block; + width: 100%; + text-align: left; + padding: var(--space-3); + margin-bottom: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + cursor: pointer; + transition: var(--transition-all); + " onmouseover="this.style.borderColor='var(--color-brand)'" onmouseout="this.style.borderColor='var(--color-border)'"> + <strong>${displayName}</strong> + <br> + <span style="color: var(--color-brand); font-weight: var(--font-semibold);">${price}${interval}</span> + ${description ? `<br><span style="font-size: var(--text-xs); color: var(--color-text-muted);">${description}</span>` : ''} + </button> + `; + }).join(''); + + // Event delegation for product selection + container.onclick = function(e) { + const btn = e.target.closest('[data-product-index]'); + if (!btn) return; + const index = parseInt(btn.dataset.productIndex, 10); + const product = invoiceProductsData[index]; + if (product) { + selectInvoiceProduct(product); + } + }; + } + + function selectInvoiceProduct(product) { + const displayName = product.display_name || product.product_name; + selectedInvoiceProduct = { + lookupKey: product.lookup_key, + productName: displayName, + amountCents: product.amount_cents + }; + + const price = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(product.amount_cents / 100); + + document.getElementById('selectedProductDisplay').innerHTML = ` + <strong>${escapeHtml(displayName)}</strong> - <span style="color: var(--color-brand);">${price}</span> + `; + + document.getElementById('invoiceProductSection').style.display = 'none'; + document.getElementById('invoiceBillingSection').style.display = 'block'; + document.getElementById('sendInvoiceBtn').style.display = 'inline-block'; + } + + async function submitInvoice(event) { + event.preventDefault(); + + if (!selectedInvoiceProduct) { + alert('Please select a product first'); + return; + } + + const btn = document.getElementById('sendInvoiceBtn'); + btn.disabled = true; + btn.textContent = 'Sending...'; + + const data = { + lookup_key: selectedInvoiceProduct.lookupKey, + company_name: document.getElementById('invoiceCompanyName').value, + contact_name: document.getElementById('invoiceContactName').value, + contact_email: document.getElementById('invoiceContactEmail').value, + billing_address: { + line1: document.getElementById('invoiceAddressLine1').value, + line2: document.getElementById('invoiceAddressLine2').value || undefined, + city: document.getElementById('invoiceCity').value, + state: document.getElementById('invoiceState').value, + postal_code: document.getElementById('invoicePostalCode').value, + country: document.getElementById('invoiceCountry').value, + } + }; + + try { + const response = await fetch(`/api/admin/prospects/${accountId}/invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || error.message || 'Failed to send invoice'); + } + + const result = await response.json(); + + if (result.success && result.invoice_url) { + document.getElementById('invoiceBillingSection').style.display = 'none'; + document.getElementById('sendInvoiceBtn').style.display = 'none'; + document.getElementById('invoiceUrl').value = result.invoice_url; + document.getElementById('invoiceResult').style.display = 'block'; + + // Refresh page to show the new pending invoice + setTimeout(() => location.reload(), 2000); + } else { + throw new Error(result.message || 'Unknown error'); + } + } catch (error) { + console.error('Error sending invoice:', error); + alert('Error: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = 'Send Invoice'; + } + } + + function copyInvoiceLink() { + const input = document.getElementById('invoiceUrl'); + input.select(); + navigator.clipboard.writeText(input.value).then(() => { + showToast('Invoice link copied to clipboard!'); + }).catch(() => { + document.execCommand('copy'); + showToast('Invoice link copied to clipboard!'); + }); } // AI Enrichment From a7f652f1ebee571817e417b511954ac951c4b9b8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:31:46 -0500 Subject: [PATCH 23/77] feat: resolve Slack usernames in Addie admin thread view (#698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolveSlackUserDisplayName and resolveSlackUserDisplayNames utilities to slack/client.ts with concurrency limiting (5 at a time) - Persist resolved users to database for future lookups - Update member-context.ts to use shared utility - Update addie-admin.ts thread endpoint to resolve mentions - Update admin-addie.html to replace <@U...> mentions with @DisplayName 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/mighty-needles-count.md | 2 + server/public/admin-addie.html | 9 +++ server/src/addie/member-context.ts | 20 +++--- server/src/routes/addie-admin.ts | 38 ++++++++++- server/src/slack/client.ts | 105 +++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 .changeset/mighty-needles-count.md 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/server/public/admin-addie.html b/server/public/admin-addie.html index 8ac3d24e60..82b38238c4 100644 --- a/server/public/admin-addie.html +++ b/server/public/admin-addie.html @@ -3120,6 +3120,7 @@ <h4>Pattern Identified</h4> 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 +3128,7 @@ <h4>Pattern Identified</h4> 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 = @@ -3443,6 +3445,13 @@ <h4>Pattern Identified</h4> } 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, '<').replace(/>/g, '>'); diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index b07df117e0..cb22cc6182 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -15,6 +15,7 @@ import { getThreadService } from './thread-service.js'; import { workos } from '../auth/workos-client.js'; import { logger } from '../logger.js'; import { getPool } from '../db/client.js'; +import { resolveSlackUserDisplayName } from '../slack/client.js'; const slackDb = new SlackDatabase(); const memberDb = new MemberDatabase(); @@ -255,22 +256,23 @@ export async function getMemberContext(slackUserId: string): Promise<MemberConte }; try { - // Step 1: Look up Slack user mapping - const slackMapping = await slackDb.getBySlackUserId(slackUserId); + // Step 1: Look up Slack user (checks DB first, then API with persistence) + const resolved = await resolveSlackUserDisplayName(slackUserId); - if (!slackMapping) { - logger.debug({ slackUserId }, 'Addie: Slack user not found in mappings'); + if (!resolved) { + logger.debug({ slackUserId }, 'Addie: Could not resolve Slack user'); return context; } context.slack_user = { - slack_user_id: slackMapping.slack_user_id, - display_name: slackMapping.slack_display_name || slackMapping.slack_real_name, - email: slackMapping.slack_email, + slack_user_id: resolved.slack_user_id, + display_name: resolved.display_name, + email: resolved.email, }; - // Step 2: Check if mapped to WorkOS user - if (!slackMapping.workos_user_id) { + // Step 2: Check if user is mapped to WorkOS (need full record for workos_user_id) + const slackMapping = await slackDb.getBySlackUserId(slackUserId); + if (!slackMapping || !slackMapping.workos_user_id) { logger.debug({ slackUserId }, 'Addie: Slack user not mapped to WorkOS'); return context; } diff --git a/server/src/routes/addie-admin.ts b/server/src/routes/addie-admin.ts index faa56795a7..cd5ef52a31 100644 --- a/server/src/routes/addie-admin.ts +++ b/server/src/routes/addie-admin.ts @@ -22,6 +22,10 @@ import { getAddieBoltApp } from "../addie/bolt-app.js"; import { AddieRouter, type RoutingContext } from "../addie/router.js"; import { sanitizeInput } from "../addie/security.js"; import { runSlackHistoryBackfill } from "../addie/jobs/slack-history-backfill.js"; +import { + resolveSlackUserDisplayName, + resolveSlackUserDisplayNames, +} from "../slack/client.js"; const logger = createLogger("addie-admin-routes"); const addieDb = new AddieDatabase(); @@ -445,7 +449,39 @@ export function createAddieAdminRouter(): { pageRouter: Router; apiRouter: Route return res.status(404).json({ error: "Thread not found" }); } - res.json(thread); + // Collect all Slack user IDs that need resolution + const userIdsToResolve: string[] = []; + const slackMentionRegex = /<@(U[A-Z0-9]+)>/g; + + // Add thread owner + if (thread.user_id && thread.channel === "slack") { + userIdsToResolve.push(thread.user_id); + } + + // Extract from all message content + for (const msg of thread.messages) { + if (msg.content) { + let match; + while ((match = slackMentionRegex.exec(msg.content)) !== null) { + userIdsToResolve.push(match[1]); + } + } + } + + // Resolve all user names with concurrency limiting + const userNames = await resolveSlackUserDisplayNames(userIdsToResolve); + + // Get display name for thread owner (may already be resolved above) + let displayName: string | null = thread.user_display_name; + if (!displayName && thread.user_id && thread.channel === "slack") { + displayName = userNames[thread.user_id] ?? null; + } + + res.json({ + ...thread, + user_display_name: displayName || thread.user_display_name, + user_names: userNames, + }); } catch (error) { logger.error({ err: error }, "Error fetching thread"); res.status(500).json({ diff --git a/server/src/slack/client.ts b/server/src/slack/client.ts index e4faa766cb..8494c497dd 100644 --- a/server/src/slack/client.ts +++ b/server/src/slack/client.ts @@ -6,6 +6,7 @@ */ import { logger } from '../logger.js'; +import { SlackDatabase } from '../db/slack-db.js'; import type { SlackUser, SlackChannel, @@ -13,6 +14,15 @@ import type { SlackBlockMessage, } from './types.js'; +// Lazy-initialized database instance for user persistence +let slackDb: SlackDatabase | null = null; +function getSlackDb(): SlackDatabase { + if (!slackDb) { + slackDb = new SlackDatabase(); + } + return slackDb; +} + // Use ADDIE_BOT_TOKEN as the primary token (fall back to SLACK_BOT_TOKEN for migration) const SLACK_BOT_TOKEN = process.env.ADDIE_BOT_TOKEN || process.env.SLACK_BOT_TOKEN; const SLACK_API_BASE = 'https://slack.com/api'; @@ -208,6 +218,101 @@ export async function getSlackUser(userId: string): Promise<SlackUser | null> { } } +/** + * Result from resolving a Slack user's display name + */ +export interface ResolvedSlackUser { + slack_user_id: string; + display_name: string | null; + email: string | null; +} + +/** + * Resolve a Slack user ID to display name, checking database first then API. + * Persists to database for future lookups. + */ +export async function resolveSlackUserDisplayName( + slackUserId: string +): Promise<ResolvedSlackUser | null> { + const db = getSlackDb(); + + // Check database first + const existing = await db.getBySlackUserId(slackUserId); + if (existing) { + return { + slack_user_id: existing.slack_user_id, + display_name: existing.slack_display_name || existing.slack_real_name, + email: existing.slack_email, + }; + } + + // Fetch from Slack API and persist + try { + const slackUser = await getSlackUser(slackUserId); + if (!slackUser) { + return null; + } + + const displayName = slackUser.profile?.display_name || + slackUser.profile?.real_name || + slackUser.real_name || + null; + const email = slackUser.profile?.email || null; + + // Persist for future requests + await db.upsertSlackUser({ + slack_user_id: slackUserId, + slack_email: email, + slack_display_name: slackUser.profile?.display_name || null, + slack_real_name: slackUser.profile?.real_name || slackUser.real_name || null, + slack_is_bot: slackUser.is_bot, + slack_is_deleted: slackUser.deleted, + }); + + logger.debug({ slackUserId, displayName }, 'Resolved and persisted Slack user from API'); + + return { + slack_user_id: slackUserId, + display_name: displayName, + email: email, + }; + } catch (error) { + logger.debug({ slackUserId, error }, 'Failed to resolve Slack user'); + return null; + } +} + +/** + * Resolve multiple Slack user IDs to display names with concurrency limiting. + * Returns a map of user ID -> display name. + */ +export async function resolveSlackUserDisplayNames( + slackUserIds: string[], + concurrency = 5 +): Promise<Record<string, string>> { + const results: Record<string, string> = {}; + const uniqueIds = [...new Set(slackUserIds)]; + + // Process in batches to avoid rate limiting + for (let i = 0; i < uniqueIds.length; i += concurrency) { + const batch = uniqueIds.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(async (userId) => { + const resolved = await resolveSlackUserDisplayName(userId); + return { userId, displayName: resolved?.display_name }; + }) + ); + + for (const { userId, displayName } of batchResults) { + if (displayName) { + results[userId] = displayName; + } + } + } + + return results; +} + /** * Look up a user by email address */ From 765d1ff9b25494bfcce798499ef8c39215343021 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:35:54 -0500 Subject: [PATCH 24/77] fix: exclude personal workspaces from Related Organizations duplicate detection (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personal workspaces should not appear in "Related Organizations (Potential Duplicates)" since they represent individual users, not companies that could be merged. This change ensures: - Personal workspaces don't show the similar orgs section when viewed - Other organizations' duplicate detection is unchanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/jolly-houses-shake.md | 2 ++ server/src/routes/admin/accounts.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/jolly-houses-shake.md 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/server/src/routes/admin/accounts.ts b/server/src/routes/admin/accounts.ts index f17b1a9a8a..bad4bcb38b 100644 --- a/server/src/routes/admin/accounts.ts +++ b/server/src/routes/admin/accounts.ts @@ -481,12 +481,14 @@ export function setupAccountRoutes( ), // Domain health: Similar organization names (potential duplicates) + // Skip for personal workspaces - they shouldn't have duplicates pool.query( ` WITH this_org AS ( SELECT workos_organization_id, name, + is_personal, LOWER(REGEXP_REPLACE( REGEXP_REPLACE(name, '\\s*(Inc\\.?|LLC|Corp\\.?|Ltd\\.?|Company|Co\\.?)\\s*$', '', 'i'), '[^a-z0-9\\s]', '', 'g' @@ -499,7 +501,6 @@ export function setupAccountRoutes( workos_organization_id, name, subscription_status, - is_personal, LOWER(REGEXP_REPLACE( REGEXP_REPLACE(name, '\\s*(Inc\\.?|LLC|Corp\\.?|Ltd\\.?|Company|Co\\.?)\\s*$', '', 'i'), '[^a-z0-9\\s]', '', 'g' @@ -520,6 +521,7 @@ export function setupAccountRoutes( OR t.normalized_name LIKE '%' || oo.normalized_name || '%' ) WHERE LENGTH(t.normalized_name) >= 3 + AND t.is_personal = false ORDER BY oo.name ASC `, [orgId] From d744c53151728abc5fb938625c8b2de4ed0f4f08 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 08:59:34 -0500 Subject: [PATCH 25/77] feat: add Google Docs integration for Addie (#685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Google Docs integration for Addie Allows Addie to read Google Docs, Sheets, and Drive files shared with her Google account. When access is denied, Addie asks the user to share the document with addie@agenticadvertising.org. Key changes: - New google-docs.ts module with OAuth2 token management - read_google_doc tool for Addie to fetch document content - Content curator now indexes Google Docs shared in public channels - OAuth setup script for initial credential configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use dotenv override to ensure .env.local values take precedence The test script was failing because existing GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET values in the shell environment were not being overwritten by dotenv. Using override: true and dynamic import ensures the correct credentials from .env.local are used. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add empty changeset for Google Docs integration This change adds Google Docs reading capability to Addie without affecting the protocol schemas. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address code review feedback for Google Docs integration - Log docId instead of full URL to avoid exposing sensitive document info - Add document ID validation with regex pattern for better security - Export error prefix constants for reliable error detection in content-curator - Use exported constants instead of hardcoded strings for error checking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/gentle-moose-greet.md | 2 + .changeset/thin-lions-trade.md | 2 + scripts/google-oauth-setup.ts | 119 ++++++ scripts/test-google-docs.ts | 58 +++ server/src/addie/bolt-app.ts | 13 + server/src/addie/mcp/google-docs.ts | 367 ++++++++++++++++++ .../src/addie/services/community-articles.ts | 12 +- server/src/addie/services/content-curator.ts | 42 ++ 8 files changed, 605 insertions(+), 10 deletions(-) create mode 100644 .changeset/gentle-moose-greet.md create mode 100644 .changeset/thin-lions-trade.md create mode 100644 scripts/google-oauth-setup.ts create mode 100644 scripts/test-google-docs.ts create mode 100644 server/src/addie/mcp/google-docs.ts 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/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/scripts/google-oauth-setup.ts b/scripts/google-oauth-setup.ts new file mode 100644 index 0000000000..18a1f6a671 --- /dev/null +++ b/scripts/google-oauth-setup.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env npx tsx +/** + * One-time OAuth setup script for Addie's Google account + * + * Usage: + * GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy npx tsx scripts/google-oauth-setup.ts + * + * This will: + * 1. Open a browser for you to sign in as Addie + * 2. Print out the refresh token to add to your environment + */ + +import http from 'http'; +import open from 'open'; +import { URL } from 'url'; + +const CLIENT_ID = process.env.GOOGLE_CLIENT_ID; +const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; +const REDIRECT_PORT = 9876; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`; + +const SCOPES = [ + 'https://www.googleapis.com/auth/documents.readonly', + 'https://www.googleapis.com/auth/drive.readonly', +]; + +if (!CLIENT_ID || !CLIENT_SECRET) { + console.error('Missing required environment variables:'); + console.error(' GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy npx tsx scripts/google-oauth-setup.ts'); + process.exit(1); +} + +async function exchangeCodeForTokens(code: string): Promise<{ refresh_token: string; access_token: string }> { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: CLIENT_ID!, + client_secret: CLIENT_SECRET!, + code, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return response.json(); +} + +async function main() { + console.log('\n🔐 Google OAuth Setup for Addie\n'); + + // Build the authorization URL + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', CLIENT_ID!); + authUrl.searchParams.set('redirect_uri', REDIRECT_URI); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', SCOPES.join(' ')); + authUrl.searchParams.set('access_type', 'offline'); + authUrl.searchParams.set('prompt', 'consent'); // Force refresh token generation + + // Start local server to receive the callback + const server = http.createServer(async (req, res) => { + const url = new URL(req.url!, `http://localhost:${REDIRECT_PORT}`); + + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`<h1>Error: ${error}</h1><p>Please try again.</p>`); + console.error(`\n❌ OAuth error: ${error}`); + process.exit(1); + } + + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('<h1>✅ Success!</h1><p>You can close this window and return to the terminal.</p>'); + + try { + const tokens = await exchangeCodeForTokens(code); + + console.log('\n✅ OAuth successful!\n'); + console.log('Add these to your environment variables:\n'); + console.log('─'.repeat(60)); + console.log(`GOOGLE_CLIENT_ID=${CLIENT_ID}`); + console.log(`GOOGLE_CLIENT_SECRET=${CLIENT_SECRET}`); + console.log(`GOOGLE_REFRESH_TOKEN=${tokens.refresh_token}`); + console.log('─'.repeat(60)); + console.log('\nFor Fly.io, run:'); + console.log(` fly secrets set GOOGLE_CLIENT_ID="${CLIENT_ID}" GOOGLE_CLIENT_SECRET="${CLIENT_SECRET}" GOOGLE_REFRESH_TOKEN="${tokens.refresh_token}"`); + console.log('\n'); + + server.close(); + process.exit(0); + } catch (err) { + console.error('\n❌ Failed to exchange code for tokens:', err); + process.exit(1); + } + } + } + }); + + server.listen(REDIRECT_PORT, () => { + console.log(`Starting local server on port ${REDIRECT_PORT}...`); + console.log('\nOpening browser for Google sign-in...'); + console.log('(Sign in as Addie\'s Google account)\n'); + + // Open the browser + open(authUrl.toString()); + }); +} + +main(); diff --git a/scripts/test-google-docs.ts b/scripts/test-google-docs.ts new file mode 100644 index 0000000000..10a380b432 --- /dev/null +++ b/scripts/test-google-docs.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env npx tsx +/** + * Quick test script for Google Docs integration + * + * Usage: + * npx tsx scripts/test-google-docs.ts <google-doc-url> + * + * Requires GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REFRESH_TOKEN env vars + */ + +// Load env from .env.local FIRST before any imports that read env vars +// Use override: true to ensure .env.local values take precedence over shell env vars +import { config } from 'dotenv'; +config({ path: '.env.local', override: true }); + +async function main() { + // Dynamic import AFTER dotenv has loaded + const { createGoogleDocsToolHandlers, isGoogleDocsUrl } = await import('../server/src/addie/mcp/google-docs.js'); + + const url = process.argv[2] || 'https://docs.google.com/document/d/1osokTr5Xk2PyLBUHbx2jpPad0TLrQIDh2I6ZWzpHMmY/edit?tab=t.0'; + + console.log('\n🔍 Testing Google Docs Integration\n'); + console.log(`URL: ${url}`); + console.log(`Is Google Docs URL: ${isGoogleDocsUrl(url)}\n`); + + // Check env vars + const hasClientId = !!process.env.GOOGLE_CLIENT_ID; + const hasClientSecret = !!process.env.GOOGLE_CLIENT_SECRET; + const hasRefreshToken = !!process.env.GOOGLE_REFRESH_TOKEN; + + console.log('Environment check:'); + console.log(` GOOGLE_CLIENT_ID: ${hasClientId ? '✓ set' : '✗ missing'}`); + console.log(` GOOGLE_CLIENT_SECRET: ${hasClientSecret ? '✓ set' : '✗ missing'}`); + console.log(` GOOGLE_REFRESH_TOKEN: ${hasRefreshToken ? '✓ set' : '✗ missing'}\n`); + + const handlers = createGoogleDocsToolHandlers(); + + if (!handlers) { + console.error('❌ Google Docs tools not available - missing credentials'); + process.exit(1); + } + + console.log('📄 Fetching document...\n'); + + try { + const result = await handlers.read_google_doc({ url }); + console.log('Result:'); + console.log('─'.repeat(60)); + console.log(result); + console.log('─'.repeat(60)); + console.log('\n✅ Test complete!'); + } catch (error) { + console.error('❌ Error:', error); + process.exit(1); + } +} + +main(); diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index 93390c713e..7b01d1e230 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -72,6 +72,7 @@ import { getCachedInsights, prefetchInsights } from './insights-cache.js'; import { getGoalsForSystemPrompt } from './services/insight-extractor.js'; import { getHomeContent, renderHomeView, renderErrorView, invalidateHomeCache } from './home/index.js'; import { URL_TOOLS, createUrlToolHandlers } from './mcp/url-tools.js'; +import { GOOGLE_DOCS_TOOLS, createGoogleDocsToolHandlers } from './mcp/google-docs.js'; import { initializeEmailHandler } from './email-handler.js'; import { isManagedChannel, @@ -343,6 +344,18 @@ export async function initializeAddieBolt(): Promise<{ app: InstanceType<typeof } } + // Register Google Docs tools (for reading Google Docs/Drive files) + const googleDocsHandlers = createGoogleDocsToolHandlers(); + if (googleDocsHandlers) { + for (const tool of GOOGLE_DOCS_TOOLS) { + const handler = googleDocsHandlers[tool.name]; + if (handler) { + claudeClient.registerTool(tool, handler); + } + } + logger.info('Addie: Google Docs tools registered'); + } + // Create the Assistant const assistant = new Assistant({ threadContextStore, diff --git a/server/src/addie/mcp/google-docs.ts b/server/src/addie/mcp/google-docs.ts new file mode 100644 index 0000000000..b93fa9ece6 --- /dev/null +++ b/server/src/addie/mcp/google-docs.ts @@ -0,0 +1,367 @@ +/** + * Google Docs Integration for Addie + * + * Allows Addie to read Google Docs shared with her Google account. + * Uses OAuth2 with a refresh token for authentication. + */ + +import { logger } from '../../logger.js'; +import type { AddieTool } from '../types.js'; + +// Addie's email for access requests +const ADDIE_EMAIL = 'addie@agenticadvertising.org'; + +// Error prefixes for reliable error detection +export const GOOGLE_DOCS_ERROR_PREFIX = 'Error:'; +export const GOOGLE_DOCS_ACCESS_DENIED_PREFIX = "I don't have access"; + +// Maximum content size (500KB) +const MAX_CONTENT_SIZE = 500 * 1024; + +// Timeout for API requests (30 seconds) +const API_TIMEOUT_MS = 30000; + +// Timeout for auth requests (10 seconds) +const AUTH_TIMEOUT_MS = 10000; + +/** + * Google OAuth2 configuration + */ +interface GoogleAuthConfig { + clientId: string; + clientSecret: string; + refreshToken: string; +} + +/** + * Token cache with mutex to prevent concurrent refresh races + */ +class GoogleAuthManager { + private accessToken: string | null = null; + private tokenExpiry: number = 0; + private refreshPromise: Promise<string> | null = null; + + constructor(private config: GoogleAuthConfig) {} + + async getAccessToken(): Promise<string> { + const now = Date.now(); + + // Return cached token if still valid (with 60s buffer) + if (this.accessToken && now < this.tokenExpiry - 60000) { + return this.accessToken; + } + + // Prevent concurrent refresh requests - if one is in flight, wait for it + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.doRefresh(); + try { + return await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + private async doRefresh(): Promise<string> { + const now = Date.now(); + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + refresh_token: this.config.refreshToken, + grant_type: 'refresh_token', + }), + signal: AbortSignal.timeout(AUTH_TIMEOUT_MS), + }); + + if (!response.ok) { + // Parse error but only log the error type, not full response (may contain tokens) + let errorType = 'unknown'; + try { + const errorText = await response.text(); + const parsed = JSON.parse(errorText); + errorType = parsed.error || parsed.error_description || 'unknown'; + } catch { + // Ignore parse errors + } + logger.error({ errorType, status: response.status }, 'Google OAuth: Failed to refresh token'); + throw new Error(`Failed to refresh Google access token: ${response.status} - ${errorType}`); + } + + const data = await response.json() as { access_token: string; expires_in?: number }; + this.accessToken = data.access_token; + // Tokens typically expire in 3600 seconds (1 hour) + this.tokenExpiry = now + (data.expires_in || 3600) * 1000; + + logger.debug('Google OAuth: Refreshed access token'); + return data.access_token; + } +} + +// Singleton auth manager (created on first use) +let authManager: GoogleAuthManager | null = null; + +function getAuthManager(config: GoogleAuthConfig): GoogleAuthManager { + if (!authManager) { + authManager = new GoogleAuthManager(config); + } + return authManager; +} + +// Google Doc IDs are typically 44 characters of base64-ish characters +// Minimum length of ~10 chars for valid IDs +const DOC_ID_REGEX = /^[a-zA-Z0-9_-]{10,}$/; + +/** + * Validate a Google Doc ID format + */ +function isValidDocId(id: string): boolean { + return DOC_ID_REGEX.test(id); +} + +/** + * Extract Google Doc/Drive ID from various URL formats + * + * Supports: + * - https://docs.google.com/document/d/DOC_ID/edit + * - https://drive.google.com/file/d/FILE_ID/view + * - https://drive.google.com/open?id=FILE_ID + * - Just the raw ID + */ +function extractDocId(urlOrId: string): string | null { + // Already just an ID (no slashes or dots indicating URL) + if (!urlOrId.includes('/') && !urlOrId.includes('.')) { + return isValidDocId(urlOrId) ? urlOrId : null; + } + + try { + const url = new URL(urlOrId); + + // docs.google.com/document/d/ID/... + const docMatch = url.pathname.match(/\/document\/d\/([a-zA-Z0-9_-]+)/); + if (docMatch && isValidDocId(docMatch[1])) return docMatch[1]; + + // drive.google.com/file/d/ID/... + const driveMatch = url.pathname.match(/\/file\/d\/([a-zA-Z0-9_-]+)/); + if (driveMatch && isValidDocId(driveMatch[1])) return driveMatch[1]; + + // drive.google.com/open?id=ID + const idParam = url.searchParams.get('id'); + if (idParam && isValidDocId(idParam)) return idParam; + + // docs.google.com/spreadsheets/d/ID/... (bonus: also works for sheets) + const sheetsMatch = url.pathname.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/); + if (sheetsMatch && isValidDocId(sheetsMatch[1])) return sheetsMatch[1]; + + return null; + } catch { + return null; + } +} + +/** + * Detect if a URL is a Google Docs/Drive URL + */ +export function isGoogleDocsUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.hostname === 'docs.google.com' || + parsed.hostname === 'drive.google.com' + ); + } catch { + return false; + } +} + +/** + * Read a Google Doc as plain text + */ +async function readGoogleDoc( + urlOrId: string, + config: GoogleAuthConfig +): Promise<string> { + const docId = extractDocId(urlOrId); + if (!docId) { + return `Error: Could not extract document ID from "${urlOrId}". Please provide a valid Google Docs or Google Drive URL.`; + } + + try { + const auth = getAuthManager(config); + const accessToken = await auth.getAccessToken(); + + // First, check what type of file this is using Drive API + const metadataResponse = await fetch( + `https://www.googleapis.com/drive/v3/files/${docId}?fields=name,mimeType,capabilities`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + } + ); + + if (!metadataResponse.ok) { + if (metadataResponse.status === 404) { + return `Error: Document not found. The document may have been deleted or the link is incorrect.`; + } + if (metadataResponse.status === 403) { + return `I don't have access to this Google Doc. Please share it with ${ADDIE_EMAIL} and let me know when you've done that.`; + } + const error = await metadataResponse.text(); + logger.error({ error, status: metadataResponse.status, docId }, 'Google Docs: Failed to get metadata'); + return `Error: Failed to access document (${metadataResponse.status})`; + } + + const metadata = await metadataResponse.json() as { name: string; mimeType: string }; + const { name, mimeType } = metadata; + + logger.debug({ docId, name, mimeType }, 'Google Docs: Retrieved metadata'); + + // Handle different file types + let exportMimeType = 'text/plain'; + let exportFormat = 'text'; + + if (mimeType === 'application/vnd.google-apps.document') { + // Google Doc - export as plain text + exportMimeType = 'text/plain'; + exportFormat = 'txt'; + } else if (mimeType === 'application/vnd.google-apps.spreadsheet') { + // Google Sheet - export as CSV + exportMimeType = 'text/csv'; + exportFormat = 'csv'; + } else if (mimeType === 'application/vnd.google-apps.presentation') { + // Google Slides - export as plain text + exportMimeType = 'text/plain'; + exportFormat = 'txt'; + } else if (mimeType === 'application/pdf') { + return `This is a PDF file (${name}). I cannot read PDF content directly. If you need me to understand the content, please copy and paste the relevant text.`; + } else if (mimeType?.startsWith('image/')) { + return `This is an image file (${name}). I can see it was shared but cannot view image contents directly.`; + } else if (mimeType === 'text/plain' || mimeType === 'text/markdown' || mimeType === 'application/json') { + // Direct download for text files + const downloadResponse = await fetch( + `https://www.googleapis.com/drive/v3/files/${docId}?alt=media`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + } + ); + + if (!downloadResponse.ok) { + return `Error: Failed to download file (${downloadResponse.status})`; + } + + const content = await downloadResponse.text(); + if (content.length > MAX_CONTENT_SIZE) { + return `**${name}**\n\n${content.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`; + } + return `**${name}**\n\n${content}`; + } else { + return `This is a ${mimeType || 'binary'} file (${name}). I cannot read the contents of this file type directly.`; + } + + // Export Google Workspace files + const exportResponse = await fetch( + `https://www.googleapis.com/drive/v3/files/${docId}/export?mimeType=${encodeURIComponent(exportMimeType)}`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + } + ); + + if (!exportResponse.ok) { + if (exportResponse.status === 403) { + return `I don't have access to export this document. Please share it with ${ADDIE_EMAIL} with at least "Viewer" permissions.`; + } + const error = await exportResponse.text(); + logger.error({ error, status: exportResponse.status, docId }, 'Google Docs: Failed to export'); + return `Error: Failed to export document (${exportResponse.status})`; + } + + const content = await exportResponse.text(); + + if (content.length > MAX_CONTENT_SIZE) { + return `**${name}** (${exportFormat})\n\n${content.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`; + } + + return `**${name}** (${exportFormat})\n\n${content}`; + } catch (error) { + logger.error({ error, docId }, 'Google Docs: Unexpected error'); + if (error instanceof Error) { + return `Error reading Google Doc: ${error.message}`; + } + return 'Error: Unknown error reading Google Doc'; + } +} + +/** + * Tool definition for reading Google Docs + */ +export const GOOGLE_DOCS_TOOLS: AddieTool[] = [ + { + name: 'read_google_doc', + description: `Read a Google Doc, Sheet, or file from Google Drive. Use this when a user shares a Google Docs or Google Drive link. If access is denied, Addie will ask the user to share the document with ${ADDIE_EMAIL}.`, + usage_hints: 'use when user shares a docs.google.com or drive.google.com link', + input_schema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The Google Docs or Google Drive URL, or the document ID', + }, + }, + required: ['url'], + }, + }, +]; + +// Track if we've already logged the missing credentials warning +let credentialsWarningLogged = false; + +/** + * Create tool handlers for Google Docs + * Returns null if Google credentials are not configured + */ +export function createGoogleDocsToolHandlers(): Record<string, (input: Record<string, unknown>) => Promise<string>> | null { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const refreshToken = process.env.GOOGLE_REFRESH_TOKEN; + + if (!clientId || !clientSecret || !refreshToken) { + // Only log once to avoid spamming logs + if (!credentialsWarningLogged) { + logger.warn('Google Docs tools not available: missing GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, or GOOGLE_REFRESH_TOKEN'); + credentialsWarningLogged = true; + } + return null; + } + + const config: GoogleAuthConfig = { clientId, clientSecret, refreshToken }; + + return { + read_google_doc: async (input: Record<string, unknown>) => { + const url = input.url; + + // Validate input + if (typeof url !== 'string' || !url.trim()) { + return 'Error: URL parameter is required and must be a non-empty string'; + } + + const docId = extractDocId(url); + logger.info({ docId }, 'Addie: Reading Google Doc'); + + const result = await readGoogleDoc(url, config); + + // Truncate if too long + if (result.length > 15000) { + return result.substring(0, 15000) + '\n\n[Content truncated to 15,000 characters]'; + } + + return result; + }, + }; +} diff --git a/server/src/addie/services/community-articles.ts b/server/src/addie/services/community-articles.ts index 43048808e2..d0bf3107d4 100644 --- a/server/src/addie/services/community-articles.ts +++ b/server/src/addie/services/community-articles.ts @@ -69,16 +69,8 @@ function isLikelyArticleUrl(url: string): boolean { return false; } - // Skip docs/sheets/slides - const googlePatterns = [ - /docs\.google\.com/, - /sheets\.google\.com/, - /slides\.google\.com/, - /drive\.google\.com/, - ]; - if (googlePatterns.some(pattern => pattern.test(urlLower))) { - return false; - } + // Google Docs/Sheets/Slides ARE valid - we can read them via Google API + // (handled specially in content-curator.ts) return true; } diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts index fcd5f223b4..2f8854c0c2 100644 --- a/server/src/addie/services/content-curator.ts +++ b/server/src/addie/services/content-curator.ts @@ -19,6 +19,12 @@ import { AddieDatabase, type KeyInsight } from '../../db/addie-db.js'; import { getPendingRssPerspectives, type RssPerspective } from '../../db/industry-feeds-db.js'; import { query } from '../../db/client.js'; import { getActiveChannels, type NotificationChannel } from '../../db/notification-channels-db.js'; +import { + isGoogleDocsUrl, + createGoogleDocsToolHandlers, + GOOGLE_DOCS_ERROR_PREFIX, + GOOGLE_DOCS_ACCESS_DENIED_PREFIX, +} from '../mcp/google-docs.js'; const addieDb = new AddieDatabase(); @@ -28,8 +34,14 @@ const CURATOR_MODEL = process.env.ADDIE_MODEL || 'claude-sonnet-4-20250514'; /** * Fetch URL content and extract article text using Mozilla Readability * This extracts just the main article content, removing navigation, ads, footers, etc. + * For Google Docs URLs, uses the Google Docs API instead of HTTP fetching. */ async function fetchUrlContent(url: string): Promise<string> { + // Handle Google Docs specially via API + if (isGoogleDocsUrl(url)) { + return fetchGoogleDocsContent(url); + } + const response = await fetch(url, { headers: { 'User-Agent': 'AddieBot/1.0 (AgenticAdvertising.org knowledge curator)', @@ -79,6 +91,36 @@ async function fetchUrlContent(url: string): Promise<string> { return text; } +/** + * Fetch content from Google Docs using the Google Docs API + */ +async function fetchGoogleDocsContent(url: string): Promise<string> { + const handlers = createGoogleDocsToolHandlers(); + + if (!handlers) { + throw new Error('Google Docs API not configured - missing credentials'); + } + + const result = await handlers.read_google_doc({ url }); + + // Check for errors in the result (using exported constants for reliable detection) + if (result.startsWith(GOOGLE_DOCS_ERROR_PREFIX) || result.startsWith(GOOGLE_DOCS_ACCESS_DENIED_PREFIX)) { + throw new Error(result); + } + + // Strip the title/format header if present (e.g., "**Document Name** (txt)\n\n") + const contentMatch = result.match(/^\*\*[^*]+\*\*[^\n]*\n\n([\s\S]*)$/); + const content = contentMatch ? contentMatch[1] : result; + + // Limit content length + const maxLength = 50000; + if (content.length > maxLength) { + return content.substring(0, maxLength) + '\n\n[Content truncated...]'; + } + + return content; +} + /** * Channel info for routing decisions */ From 6119f96dcc447bd85ffab3f6fa9d49d90e788e63 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 10:28:20 -0500 Subject: [PATCH 26/77] fix: block merging personal workspaces with company organizations (#701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personal workspaces represent individual users and should never be merged with company organizations. This adds defense-in-depth: - Block the merge in mergeOrganizations() with a clear error message - Show prominent warnings in previewMerge() if personal workspaces are incorrectly included in merge candidates - Return 400 (not 500) for validation errors in the merge endpoint This prevents accidental merges even if upstream duplicate detection fails to filter personal workspaces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/block-personal-workspace-merge.md | 2 ++ server/src/db/org-merge-db.ts | 19 ++++++++++++++++--- server/src/routes/admin/cleanup.ts | 9 +++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 .changeset/block-personal-workspace-merge.md 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/server/src/db/org-merge-db.ts b/server/src/db/org-merge-db.ts index 93d9737385..4d891d8377 100644 --- a/server/src/db/org-merge-db.ts +++ b/server/src/db/org-merge-db.ts @@ -68,7 +68,7 @@ export async function mergeOrganizations( // Validate both organizations exist and fetch all needed fields const orgsResult = await client.query( - `SELECT workos_organization_id, name, prospect_notes, + `SELECT workos_organization_id, name, is_personal, prospect_notes, enrichment_at, enrichment_industry, enrichment_sub_industry, enrichment_employee_count, enrichment_revenue, enrichment_revenue_range, enrichment_country, enrichment_city, enrichment_description @@ -88,6 +88,11 @@ export async function mergeOrganizations( throw new Error('Could not load organization details'); } + // Block merging personal workspaces + if (primaryOrg.is_personal || secondaryOrg.is_personal) { + throw new Error('Cannot merge personal workspaces. Personal workspaces represent individual users and should not be merged with company organizations.'); + } + logger.info( { primary: primaryOrg.name, secondary: secondaryOrg.name }, 'Merging organizations' @@ -662,9 +667,9 @@ export async function previewMerge( }> { const pool = getPool(); - // Get organization names + // Get organization names and personal workspace status const orgsResult = await pool.query( - `SELECT workos_organization_id, name FROM organizations + `SELECT workos_organization_id, name, is_personal FROM organizations WHERE workos_organization_id = ANY($1)`, [[primaryOrgId, secondaryOrgId]] ); @@ -681,6 +686,14 @@ export async function previewMerge( } const warnings: string[] = []; + + // Warn if either organization is a personal workspace + if (primaryOrg.is_personal) { + warnings.unshift(`🔴 PRIMARY IS PERSONAL WORKSPACE: "${primaryOrg.name}" is a personal workspace and should not be merged with company organizations.`); + } + if (secondaryOrg.is_personal) { + warnings.unshift(`🔴 SECONDARY IS PERSONAL WORKSPACE: "${secondaryOrg.name}" is a personal workspace and should not be merged with company organizations.`); + } const estimatedChanges: { table_name: string; rows_to_move: number }[] = []; // Count rows in each table diff --git a/server/src/routes/admin/cleanup.ts b/server/src/routes/admin/cleanup.ts index f81028d453..24afee2c36 100644 --- a/server/src/routes/admin/cleanup.ts +++ b/server/src/routes/admin/cleanup.ts @@ -307,6 +307,15 @@ export function setupCleanupRoutes(apiRouter: Router): void { res.json(result); } catch (error) { logger.error({ err: error }, "Error executing merge"); + + // Return 400 for validation errors (e.g., personal workspace merge attempts) + if (error instanceof Error && error.message.startsWith('Cannot merge personal workspaces')) { + return res.status(400).json({ + error: "Validation error", + message: error.message, + }); + } + res.status(500).json({ error: "Internal server error", message: From d26ae806c44480f9596e4e994585c446d43638a7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 10:40:45 -0500 Subject: [PATCH 27/77] feat: enhance user linking with Addie emails and timezone-aware outreach (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update signup emails to come from Addie with personalized content - Different CTAs for Slack vs non-Slack users in signup emails - Add timezone-aware business hours for proactive outreach - Store Slack timezone offset in slack_user_mappings table - Auto-link website and Slack accounts by email on signup - Add OUTREACH_ENABLED kill switch for emergency disabling - Add admin endpoints for website-only users and Slack invites 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> --- .changeset/seven-heads-smile.md | 2 + .../src/addie/services/proactive-outreach.ts | 155 ++++------ .../src/db/migrations/154_slack_tz_offset.sql | 7 + server/src/db/slack-db.ts | 7 +- server/src/http.ts | 13 +- server/src/notifications/email.ts | 271 +++++++++++++++--- server/src/routes/admin/users.ts | 187 ++++++++++++ server/src/routes/workos-webhooks.ts | 32 ++- server/src/slack/sync.ts | 123 +++++++- server/src/slack/types.ts | 2 + 10 files changed, 653 insertions(+), 146 deletions(-) create mode 100644 .changeset/seven-heads-smile.md create mode 100644 server/src/db/migrations/154_slack_tz_offset.sql 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/server/src/addie/services/proactive-outreach.ts b/server/src/addie/services/proactive-outreach.ts index 382427a306..271d0cda19 100644 --- a/server/src/addie/services/proactive-outreach.ts +++ b/server/src/addie/services/proactive-outreach.ts @@ -4,12 +4,6 @@ * Manages proactive outreach to Slack users via DMs. * Uses the OutboundPlanner for intelligent goal selection. * Handles eligibility checking, rate limiting, business hours, and A/B testing. - * - * Outreach modes (controlled by OUTREACH_MODE env var): - * - disabled: No outreach (default) - * - test: Only message accounts in outreach_test_accounts table - * - dry_run: Log what would be sent without actually sending - * - live: Full production mode */ import { logger } from '../../logger.js'; @@ -30,9 +24,11 @@ import type { SlackUserMapping } from '../../slack/types.js'; const insightsDb = new InsightsDatabase(); -// Outreach mode -type OutreachMode = 'disabled' | 'test' | 'dry_run' | 'live'; -const OUTREACH_MODE = (process.env.OUTREACH_MODE || 'disabled') as OutreachMode; +// Outreach is always live - rate limiting and business hours provide safety +const OUTREACH_MODE = 'live' as const; + +// Emergency kill switch - set OUTREACH_ENABLED=false to disable all outreach +const OUTREACH_ENABLED = process.env.OUTREACH_ENABLED !== 'false'; // Configuration const RATE_LIMIT_DAYS = 7; // Don't contact same user more than once per week @@ -49,6 +45,7 @@ interface OutreachCandidate { slack_real_name: string | null; workos_user_id: string | null; last_outreach_at: Date | null; + slack_tz_offset: number | null; priority: number; } @@ -68,27 +65,46 @@ interface OutreachResult { } /** - * Check if current time is within business hours (9am-5pm ET weekdays) + * Check if current time is within business hours (9am-5pm weekdays) + * Uses user's timezone if provided, otherwise defaults to ET + * + * @param tzOffsetSeconds - Slack timezone offset in seconds from UTC (e.g., -18000 for ET) */ -export function isBusinessHours(): boolean { +export function isBusinessHours(tzOffsetSeconds?: number | null): boolean { const now = new Date(); - // Get ET timezone offset (handle DST) - const etOffset = getEasternTimezoneOffset(now); - const etHour = (now.getUTCHours() - etOffset + 24) % 24; - const day = now.getUTCDay(); + // Slack provides tz_offset in seconds, convert to hours + // If no timezone provided, default to Eastern Time + let offsetHours: number; + if (tzOffsetSeconds != null) { + offsetHours = tzOffsetSeconds / 3600; + } else { + // Fall back to ET (handle DST) + offsetHours = -getEasternTimezoneOffset(now); + } + + // Calculate user's local hour + // offsetHours is negative for west of UTC (e.g., -5 for ET) + const userLocalHour = (now.getUTCHours() + offsetHours + 24) % 24; + + // Get day of week in user's timezone + const utcTimestamp = now.getTime(); + const userLocalTimestamp = utcTimestamp + offsetHours * 3600 * 1000; + const userLocalDate = new Date(userLocalTimestamp); + const day = userLocalDate.getUTCDay(); // Weekend check if (day === 0 || day === 6) { return false; } - // Business hours check - return etHour >= BUSINESS_HOURS_START && etHour < BUSINESS_HOURS_END; + // Business hours check (9am-5pm in user's timezone) + return userLocalHour >= BUSINESS_HOURS_START && userLocalHour < BUSINESS_HOURS_END; } /** * Get Eastern timezone offset (handles DST) + * Returns positive number (hours behind UTC) */ function getEasternTimezoneOffset(date: Date): number { // ET is UTC-5 (EST) or UTC-4 (EDT) @@ -254,30 +270,6 @@ async function getEligibleCandidates(limit = 10): Promise<OutreachCandidate[]> { })); } -/** - * Get test account candidates (for OUTREACH_MODE=test) - */ -async function getTestAccountCandidates(): Promise<OutreachCandidate[]> { - const testAccounts = await insightsDb.listTestAccounts(); - const slackUserIds = testAccounts.map(a => a.slack_user_id); - - if (slackUserIds.length === 0) { - return []; - } - - const result = await query<SlackUserMapping>( - `SELECT * FROM slack_user_mappings WHERE slack_user_id = ANY($1)`, - [slackUserIds] - ); - - return result.rows - .filter(isUserEligible) - .map(user => ({ - ...user, - priority: calculatePriority(user), - })); -} - /** * Select an A/B test variant using weighted random selection */ @@ -445,19 +437,6 @@ async function initiateOutreachWithPlanner(candidate: OutreachCandidate): Promis const linkUrl = `https://agenticadvertising.org/auth/login?slack_user_id=${encodeURIComponent(candidate.slack_user_id)}`; const message = planner.buildMessage(plannedAction.goal, ctx, linkUrl); - // DRY_RUN mode: log but don't send - if (OUTREACH_MODE === 'dry_run') { - logger.info({ - mode: 'dry_run', - candidate: candidate.slack_user_id, - goal: plannedAction.goal.name, - reason: plannedAction.reason, - decision_method: plannedAction.decision_method, - message: message.substring(0, 100) + '...', - }, 'DRY RUN: Would send planned outreach'); - return { success: true }; - } - // Open DM channel const channelId = await openDmChannel(candidate.slack_user_id); if (!channelId) { @@ -535,18 +514,6 @@ async function initiateOutreach(candidate: OutreachCandidate): Promise<OutreachR // Build message const message = buildMessage(variant, candidate, goal); - // DRY_RUN mode: log but don't send - if (OUTREACH_MODE === 'dry_run') { - logger.info({ - mode: 'dry_run', - candidate: candidate.slack_user_id, - outreachType, - variant: variant.name, - message: message.substring(0, 100) + '...', - }, 'DRY RUN: Would send outreach'); - return { success: true }; - } - // Open DM channel const channelId = await openDmChannel(candidate.slack_user_id); if (!channelId) { @@ -607,24 +574,16 @@ export async function runOutreachScheduler(options: { const limit = options.limit ?? 5; const usePlanner = options.usePlanner ?? true; - // Check if outreach is enabled - if (OUTREACH_MODE === 'disabled') { - logger.debug('Outreach scheduler: Mode is disabled'); + // Check kill switch + if (!OUTREACH_ENABLED) { + logger.info('Outreach scheduler: Disabled via OUTREACH_ENABLED=false'); return { processed: 0, sent: 0, skipped: 0, errors: 0 }; } - // Check business hours (unless forced) - if (!options.forceRun && !isBusinessHours()) { - logger.debug('Outreach scheduler: Outside business hours'); - return { processed: 0, sent: 0, skipped: 0, errors: 0 }; - } + logger.info({ limit, usePlanner }, 'Running outreach scheduler'); - logger.info({ mode: OUTREACH_MODE, limit, usePlanner }, 'Running outreach scheduler'); - - // Get candidates based on mode - const candidates = OUTREACH_MODE === 'test' - ? await getTestAccountCandidates() - : await getEligibleCandidates(limit); + // Get candidates (we'll check business hours per-user based on their timezone) + const candidates = await getEligibleCandidates(limit * 3); // Fetch more since some may be outside business hours if (candidates.length === 0) { logger.info('Outreach scheduler: No eligible candidates'); @@ -637,7 +596,22 @@ export async function runOutreachScheduler(options: { let skipped = 0; let errors = 0; - for (const candidate of candidates.slice(0, limit)) { + for (const candidate of candidates) { + // Stop once we've sent enough messages + if (sent >= limit) { + break; + } + + // Check business hours in user's timezone (unless forced) + if (!options.forceRun && !isBusinessHours(candidate.slack_tz_offset)) { + logger.debug({ + candidate: candidate.slack_user_id, + tzOffset: candidate.slack_tz_offset, + }, 'Skipped - outside business hours in user timezone'); + skipped++; + continue; + } + try { const result = usePlanner ? await initiateOutreachWithPlanner(candidate) @@ -794,6 +768,7 @@ export async function manualOutreachWithGoal( slack_real_name: user.slack_real_name, workos_user_id: user.workos_user_id, last_outreach_at: user.last_outreach_at, + slack_tz_offset: user.slack_tz_offset, priority: calculatePriority(user), }; const ctx = await buildPlannerContext(candidate); @@ -802,20 +777,6 @@ export async function manualOutreachWithGoal( const linkUrl = `https://agenticadvertising.org/auth/login?slack_user_id=${encodeURIComponent(slackUserId)}`; const message = planner.buildMessage(goal, ctx, linkUrl); - // DRY_RUN mode: log but don't send - if (OUTREACH_MODE === 'dry_run') { - logger.info({ - mode: 'dry_run', - candidate: slackUserId, - goal: goal.name, - goalId: goal.id, - triggeredBy: triggeredBy?.id, - hasAdminContext: !!adminContext, - message: message.substring(0, 100) + '...', - }, 'DRY RUN: Would send admin-override outreach'); - return { success: true }; - } - // Open DM channel const channelId = await openDmChannel(slackUserId); if (!channelId) { @@ -889,9 +850,9 @@ export async function manualOutreachWithGoal( } /** - * Get current outreach mode + * Get current outreach mode (always 'live') */ -export function getOutreachMode(): OutreachMode { +export function getOutreachMode(): 'live' { return OUTREACH_MODE; } diff --git a/server/src/db/migrations/154_slack_tz_offset.sql b/server/src/db/migrations/154_slack_tz_offset.sql new file mode 100644 index 0000000000..06f19d74a7 --- /dev/null +++ b/server/src/db/migrations/154_slack_tz_offset.sql @@ -0,0 +1,7 @@ +-- Add timezone offset column to slack_user_mappings +-- Slack provides tz_offset in seconds from UTC + +ALTER TABLE slack_user_mappings +ADD COLUMN IF NOT EXISTS slack_tz_offset INTEGER; + +COMMENT ON COLUMN slack_user_mappings.slack_tz_offset IS 'Timezone offset in seconds from UTC, from Slack user profile'; diff --git a/server/src/db/slack-db.ts b/server/src/db/slack-db.ts index f377dff14c..350b156fb7 100644 --- a/server/src/db/slack-db.ts +++ b/server/src/db/slack-db.ts @@ -29,12 +29,13 @@ export class SlackDatabase { slack_real_name: string | null; slack_is_bot: boolean; slack_is_deleted: boolean; + slack_tz_offset?: number | null; }): Promise<SlackUserMapping> { const result = await query<SlackUserMapping>( `INSERT INTO slack_user_mappings ( slack_user_id, slack_email, slack_display_name, slack_real_name, - slack_is_bot, slack_is_deleted, last_slack_sync_at - ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) + slack_is_bot, slack_is_deleted, slack_tz_offset, last_slack_sync_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (slack_user_id) DO UPDATE SET slack_email = EXCLUDED.slack_email, @@ -42,6 +43,7 @@ export class SlackDatabase { slack_real_name = EXCLUDED.slack_real_name, slack_is_bot = EXCLUDED.slack_is_bot, slack_is_deleted = EXCLUDED.slack_is_deleted, + slack_tz_offset = EXCLUDED.slack_tz_offset, last_slack_sync_at = NOW(), updated_at = NOW() RETURNING *`, @@ -52,6 +54,7 @@ export class SlackDatabase { user.slack_real_name, user.slack_is_bot, user.slack_is_deleted, + user.slack_tz_offset ?? null, ] ); diff --git a/server/src/http.ts b/server/src/http.ts index 82a00737ad..6385a7f242 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -4240,6 +4240,16 @@ Disallow: /api/admin/ const workosOrg = await workos!.organizations.getOrganization(orgId); const hasActiveSubscription = org?.subscription_status === 'active'; + // Check if user is linked to Slack (to decide whether to include Slack invite) + let isLinkedToSlack = false; + try { + const slackDb = new SlackDatabase(); + const slackMapping = await slackDb.getByWorkosUserId(user.id); + isLinkedToSlack = !!slackMapping?.slack_user_id; + } catch (slackError) { + logger.warn({ error: slackError, userId: user.id }, 'Failed to check Slack mapping, defaulting to not linked'); + } + await sendUserSignupEmail({ to: user.email, firstName: user.firstName || undefined, @@ -4247,9 +4257,10 @@ Disallow: /api/admin/ hasActiveSubscription, workosUserId: user.id, workosOrganizationId: orgId, + isLinkedToSlack, }); - logger.info({ userId: user.id, orgId, hasActiveSubscription }, 'First-time user signup email sent'); + logger.info({ userId: user.id, orgId, hasActiveSubscription, isLinkedToSlack }, 'First-time user signup email sent'); } catch (emailError) { logger.error({ error: emailError, userId: user.id }, 'Failed to send signup email'); } diff --git a/server/src/notifications/email.ts b/server/src/notifications/email.ts index e75e87caaf..4e7ece9c50 100644 --- a/server/src/notifications/email.ts +++ b/server/src/notifications/email.ts @@ -19,6 +19,7 @@ if (!RESEND_API_KEY) { } const FROM_EMAIL = 'AgenticAdvertising.org <hello@updates.agenticadvertising.org>'; +const FROM_EMAIL_ADDIE = 'Addie from AgenticAdvertising.org <addie@updates.agenticadvertising.org>'; const BASE_URL = process.env.BASE_URL || 'https://agenticadvertising.org'; /** @@ -100,7 +101,12 @@ async function getUnsubscribeToken(workosUserId: string, email: string): Promise /** * Email types for tracking */ -export type EmailType = 'welcome_member' | 'signup_user' | 'signup_user_member' | 'signup_user_nonmember'; +export type EmailType = + | 'welcome_member' + | 'signup_user' + | 'signup_user_member' + | 'signup_user_nonmember' + | 'slack_invite'; /** * Send welcome email to new members after subscription is created @@ -280,6 +286,7 @@ export async function sendUserSignupEmail(data: { hasActiveSubscription: boolean; workosUserId?: string; workosOrganizationId?: string; + isLinkedToSlack?: boolean; // If true, skip Slack invite section }): Promise<boolean> { if (!resend) { logger.debug('Resend not configured, skipping signup email'); @@ -295,19 +302,19 @@ export async function sendUserSignupEmail(data: { } } - const greeting = data.firstName ? `Hi ${data.firstName},` : 'Hi there,'; + const greeting = data.firstName ? `Hi ${data.firstName}!` : 'Hi there!'; const emailType: EmailType = data.hasActiveSubscription ? 'signup_user_member' : 'signup_user_nonmember'; // Different content based on subscription status const { subject, ctaText, ctaDestination, ctaLinkName } = data.hasActiveSubscription ? { - subject: `Welcome to ${data.organizationName || 'your team'} on AgenticAdvertising.org`, + subject: `Welcome to AgenticAdvertising.org! I'm Addie, your AI assistant`, ctaText: 'Go to Dashboard', ctaDestination: 'https://agenticadvertising.org/dashboard', ctaLinkName: 'cta_dashboard', } : { - subject: 'Welcome to AgenticAdvertising.org', + subject: `Welcome to AgenticAdvertising.org! I'm Addie`, ctaText: 'Become a Member', ctaDestination: 'https://agenticadvertising.org/dashboard/membership', ctaLinkName: 'cta_membership', @@ -332,6 +339,7 @@ export async function sendUserSignupEmail(data: { // Build tracked URLs const ctaUrl = trackedUrl(trackingId, ctaLinkName, ctaDestination); + const slackUrl = trackedUrl(trackingId, 'cta_slack_invite', SLACK_INVITE_URL); // Signup email is transactional - no unsubscribe link // Future marketing emails will include unsubscribe via: @@ -339,38 +347,67 @@ export async function sendUserSignupEmail(data: { const footerHtml = generateFooterHtml(trackingId, null); const footerText = generateFooterText(null); + // Addie section - different content based on whether user is on Slack + const addieSectionHtml = data.isLinkedToSlack + ? ` + <div style="background: #f0f9ff; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #2563eb;"> + <p style="margin: 0 0 10px 0;"><strong>Need help? I'm here!</strong></p> + <p style="margin: 0; font-size: 14px;">I noticed you're already on Slack - you can DM me anytime at <strong>@Addie</strong>. I can help you find members, answer questions about the community, or just chat about agentic advertising.</p> + </div>` + : ` + <div style="background: #f8f4ff; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #4A154B;"> + <p style="margin: 0 0 10px 0;"><strong>Join our Slack community!</strong></p> + <p style="margin: 0 0 15px 0; font-size: 14px;">Most of our community hangs out in Slack - it's where the conversations happen! You can also DM me there anytime.</p> + <a href="${slackUrl}" style="background-color: #4A154B; color: white; padding: 8px 16px; text-decoration: none; border-radius: 4px; display: inline-block; font-size: 14px;">Join Slack</a> + </div>`; + + const addieSectionText = data.isLinkedToSlack + ? ` +--- +NEED HELP? I'M HERE! +I noticed you're already on Slack - you can DM me anytime at @Addie. I can help you find members, answer questions about the community, or just chat about agentic advertising. +--- +` + : ` +--- +JOIN OUR SLACK COMMUNITY +Most of our community hangs out in Slack - it's where the conversations happen! You can also DM me there anytime. +Join Slack: ${SLACK_INVITE_URL} +--- +`; + const mainContent = data.hasActiveSubscription ? ` <p>${greeting}</p> - <p>You've joined <strong>${data.organizationName || 'your organization'}</strong> on AgenticAdvertising.org. Your team is already a member!</p> + <p>I'm Addie, the AI assistant for AgenticAdvertising.org. Welcome! I see you've joined <strong>${data.organizationName || 'your organization'}</strong> - great to have you here.</p> - <p>Here's what you can do:</p> + <p>Since your team is already a member, you have full access to everything:</p> <ul style="padding-left: 20px;"> - <li><strong>View the Member Directory</strong> - Connect with other members building agentic advertising</li> - <li><strong>Access your Dashboard</strong> - Manage your organization's profile and settings</li> - <li><strong>Invite Teammates</strong> - Add more people from your organization</li> + <li><strong>Member Directory</strong> - Find and connect with others building agentic advertising</li> + <li><strong>Your Dashboard</strong> - Manage your organization's profile and settings</li> + <li><strong>Invite Teammates</strong> - Bring more people from your team on board</li> </ul> - <p>Get started by visiting your dashboard:</p>` + <p>Here's your dashboard:</p>` : ` <p>${greeting}</p> - <p>Thanks for signing up for AgenticAdvertising.org${data.organizationName ? ` with <strong>${data.organizationName}</strong>` : ''}!</p> + <p>I'm Addie, the AI assistant for AgenticAdvertising.org. Thanks for signing up${data.organizationName ? ` with <strong>${data.organizationName}</strong>` : ''}!</p> - <p>You've created an account, but your organization isn't a member yet. Membership gives you access to:</p> + <p>You've created an account, but your organization isn't a member yet. Membership unlocks:</p> <ul style="padding-left: 20px;"> <li><strong>Member Directory</strong> - Connect with companies building agentic advertising</li> - <li><strong>Working Groups</strong> - Participate in shaping the future of AdCP</li> - <li><strong>Member Profile</strong> - Showcase your organization's capabilities</li> + <li><strong>Working Groups</strong> - Help shape the future of AdCP</li> + <li><strong>Member Profile</strong> - Show off what your organization does</li> </ul> - <p>Ready to become a member?</p>`; + <p>Want to become a member?</p>`; const { data: sendData, error } = await resend.emails.send({ - from: FROM_EMAIL, + from: FROM_EMAIL_ADDIE, to: data.to, subject, html: ` @@ -391,11 +428,13 @@ export async function sendUserSignupEmail(data: { <a href="${ctaUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 500;">${ctaText}</a> </p> - <p>If you have any questions, just reply to this email - we're happy to help.</p> + ${addieSectionHtml} + + <p>If you have any questions, just reply to this email and I'll help you out!</p> <p style="margin-top: 30px;"> - Best,<br> - The AgenticAdvertising.org Team + Talk soon,<br> + Addie </p> ${footerHtml} </body> @@ -404,42 +443,42 @@ export async function sendUserSignupEmail(data: { text: data.hasActiveSubscription ? `Welcome! -${data.firstName ? `Hi ${data.firstName},` : 'Hi there,'} +${data.firstName ? `Hi ${data.firstName}!` : 'Hi there!'} -You've joined ${data.organizationName || 'your organization'} on AgenticAdvertising.org. Your team is already a member! +I'm Addie, the AI assistant for AgenticAdvertising.org. Welcome! I see you've joined ${data.organizationName || 'your organization'} - great to have you here. -Here's what you can do: -- View the Member Directory - Connect with other members building agentic advertising -- Access your Dashboard - Manage your organization's profile and settings -- Invite Teammates - Add more people from your organization +Since your team is already a member, you have full access to everything: +- Member Directory - Find and connect with others building agentic advertising +- Your Dashboard - Manage your organization's profile and settings +- Invite Teammates - Bring more people from your team on board -Get started by visiting your dashboard: +Here's your dashboard: https://agenticadvertising.org/dashboard +${addieSectionText} +If you have any questions, just reply to this email and I'll help you out! -If you have any questions, just reply to this email - we're happy to help. - -Best, -The AgenticAdvertising.org Team +Talk soon, +Addie ${footerText}` : `Welcome! -${data.firstName ? `Hi ${data.firstName},` : 'Hi there,'} +${data.firstName ? `Hi ${data.firstName}!` : 'Hi there!'} -Thanks for signing up for AgenticAdvertising.org${data.organizationName ? ` with ${data.organizationName}` : ''}! +I'm Addie, the AI assistant for AgenticAdvertising.org. Thanks for signing up${data.organizationName ? ` with ${data.organizationName}` : ''}! -You've created an account, but your organization isn't a member yet. Membership gives you access to: +You've created an account, but your organization isn't a member yet. Membership unlocks: - Member Directory - Connect with companies building agentic advertising -- Working Groups - Participate in shaping the future of AdCP -- Member Profile - Showcase your organization's capabilities +- Working Groups - Help shape the future of AdCP +- Member Profile - Show off what your organization does -Ready to become a member? +Want to become a member? https://agenticadvertising.org/dashboard/membership +${addieSectionText} +If you have any questions, just reply to this email and I'll help you out! -If you have any questions, just reply to this email - we're happy to help. - -Best, -The AgenticAdvertising.org Team +Talk soon, +Addie ${footerText}`, }); @@ -869,5 +908,157 @@ Update your profile: https://agenticadvertising.org/member-profile } } +// ============== Slack Invite Email ============== + +export const SLACK_INVITE_URL = process.env.SLACK_INVITE_URL || 'https://join.slack.com/t/agenticads/shared_invite/your-invite-link'; + +/** + * Check if we've already sent a Slack invite email to this user + */ +export async function hasSlackInviteBeenSent(workosUserId: string): Promise<boolean> { + return emailDb.hasEmailBeenSent({ + email_type: 'slack_invite', + workos_user_id: workosUserId, + }); +} + +/** + * Send Slack invite email to website-only users + * These are users who have a website account but aren't in Slack yet + */ +export async function sendSlackInviteEmail(data: { + to: string; + firstName?: string; + workosUserId: string; + workosOrganizationId?: string; +}): Promise<boolean> { + if (!resend) { + logger.debug('Resend not configured, skipping Slack invite email'); + return false; + } + + // Check if already sent + const alreadySent = await hasSlackInviteBeenSent(data.workosUserId); + if (alreadySent) { + logger.debug({ userId: data.workosUserId }, 'Slack invite email already sent to this user, skipping'); + return true; // Return true since this isn't a failure + } + + const emailType: EmailType = 'slack_invite'; + const subject = 'Join the AgenticAdvertising.org Slack community'; + const greeting = data.firstName ? `Hi ${data.firstName},` : 'Hi there,'; + + try { + // Create tracking record first + const emailEvent = await emailDb.createEmailEvent({ + email_type: emailType, + recipient_email: data.to, + subject, + workos_user_id: data.workosUserId, + workos_organization_id: data.workosOrganizationId, + metadata: {}, + }); + + const trackingId = emailEvent.tracking_id; + + // Build tracked URLs + const slackUrl = trackedUrl(trackingId, 'cta_slack_invite', SLACK_INVITE_URL); + + // Get unsubscribe token for marketing email + const unsubscribeToken = await getUnsubscribeToken(data.workosUserId, data.to); + const footerHtml = generateFooterHtml(trackingId, unsubscribeToken, 'community updates'); + const footerText = generateFooterText(unsubscribeToken, 'community updates'); + + const { data: sendData, error } = await resend.emails.send({ + from: FROM_EMAIL, + to: data.to, + subject, + headers: { + 'List-Unsubscribe': `<${BASE_URL}/unsubscribe/${unsubscribeToken}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + html: ` +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> +</head> +<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;"> + <div style="text-align: center; margin-bottom: 30px;"> + <h1 style="color: #1a1a1a; font-size: 24px; margin: 0;">Join our Slack community!</h1> + </div> + + <p>${greeting}</p> + + <p>Thanks for being part of AgenticAdvertising.org! We wanted to let you know about our <strong>Slack community</strong> where members connect, share ideas, and collaborate on agentic advertising.</p> + + <p>In Slack, you can:</p> + + <ul style="padding-left: 20px;"> + <li><strong>Connect with other members</strong> working on AI-powered advertising</li> + <li><strong>Join working groups</strong> and participate in discussions</li> + <li><strong>Get updates</strong> on events, specs, and community news</li> + <li><strong>Ask questions</strong> and get help from the community</li> + </ul> + + <p style="text-align: center; margin: 30px 0;"> + <a href="${slackUrl}" style="background-color: #4A154B; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 500;">Join Slack</a> + </p> + + <p style="font-size: 14px; color: #666;">Already have a Slack account? Just use the same email address you used to sign up for the website, and your accounts will be automatically linked.</p> + + <p>See you in Slack!</p> + + <p style="margin-top: 30px;"> + Best,<br> + The AgenticAdvertising.org Team + </p> + ${footerHtml} +</body> +</html> + `.trim(), + text: ` +Join our Slack community! + +${greeting} + +Thanks for being part of AgenticAdvertising.org! We wanted to let you know about our Slack community where members connect, share ideas, and collaborate on agentic advertising. + +In Slack, you can: +- Connect with other members working on AI-powered advertising +- Join working groups and participate in discussions +- Get updates on events, specs, and community news +- Ask questions and get help from the community + +Join Slack: ${SLACK_INVITE_URL} + +Already have a Slack account? Just use the same email address you used to sign up for the website, and your accounts will be automatically linked. + +See you in Slack! + +Best, +The AgenticAdvertising.org Team + +${footerText} + `.trim(), + }); + + if (error) { + logger.error({ error, to: data.to, trackingId }, 'Failed to send Slack invite email'); + return false; + } + + // Mark as sent with Resend's email ID + await emailDb.markEmailSent(trackingId, sendData?.id); + + logger.info({ to: data.to, trackingId }, 'Slack invite email sent'); + return true; + } catch (error) { + logger.error({ error, to: data.to }, 'Error sending Slack invite email'); + return false; + } +} + // Re-export for use in routes export { emailDb, emailPrefsDb, getUnsubscribeToken }; diff --git a/server/src/routes/admin/users.ts b/server/src/routes/admin/users.ts index e38cc97cd5..7b9be435a0 100644 --- a/server/src/routes/admin/users.ts +++ b/server/src/routes/admin/users.ts @@ -14,6 +14,7 @@ import { SlackDatabase } from '../../db/slack-db.js'; import { WorkingGroupDatabase } from '../../db/working-group-db.js'; import { getPool } from '../../db/client.js'; import { backfillOrganizationMemberships, backfillUsers } from '../workos-webhooks.js'; +import { sendSlackInviteEmail, hasSlackInviteBeenSent } from '../../notifications/email.js'; const logger = createLogger('admin-users-routes'); @@ -427,5 +428,191 @@ export function createAdminUsersRouter(): Router { } }); + // GET /api/admin/users/website-only - Get users who have website accounts but not Slack + // These are candidates for Slack invite emails + router.get('/website-only', requireAuth, requireAdmin, async (_req, res) => { + try { + const pool = getPool(); + + // Get users who: + // 1. Have an organization membership (website account) + // 2. Are NOT linked to a Slack account + // 3. Don't have a matching email in slack_user_mappings (not in Slack at all) + const result = await pool.query<{ + workos_user_id: string; + email: string; + first_name: string | null; + last_name: string | null; + org_name: string; + workos_organization_id: string; + slack_invite_sent: boolean; + }>(` + SELECT DISTINCT ON (om.workos_user_id) + om.workos_user_id, + om.email, + om.first_name, + om.last_name, + o.name as org_name, + om.workos_organization_id, + EXISTS ( + SELECT 1 FROM email_events ee + WHERE ee.workos_user_id = om.workos_user_id + AND ee.email_type = 'slack_invite' + AND ee.sent_at IS NOT NULL + ) as slack_invite_sent + FROM organization_memberships om + INNER JOIN organizations o ON om.workos_organization_id = o.workos_organization_id + LEFT JOIN slack_user_mappings sm_linked ON sm_linked.workos_user_id = om.workos_user_id + LEFT JOIN slack_user_mappings sm_email ON LOWER(sm_email.slack_email) = LOWER(om.email) + WHERE sm_linked.workos_user_id IS NULL -- Not linked to Slack + AND sm_email.slack_user_id IS NULL -- Email not in Slack either + ORDER BY om.workos_user_id, o.name + `); + + const users = result.rows.map(row => ({ + workos_user_id: row.workos_user_id, + email: row.email, + name: [row.first_name, row.last_name].filter(Boolean).join(' ') || null, + first_name: row.first_name, + org_name: row.org_name, + workos_organization_id: row.workos_organization_id, + slack_invite_sent: row.slack_invite_sent, + })); + + res.json({ + users, + count: users.length, + not_invited_count: users.filter(u => !u.slack_invite_sent).length, + }); + } catch (error) { + logger.error({ err: error }, 'Get website-only users error'); + res.status(500).json({ + error: 'Failed to get website-only users', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/admin/users/send-slack-invites - Send Slack invite emails to website-only users + // Can send to all uninvited users or specific user IDs + router.post('/send-slack-invites', requireAuth, requireAdmin, async (req, res) => { + try { + const { user_ids, send_to_all } = req.body; + const pool = getPool(); + + let targetUsers: Array<{ + workos_user_id: string; + email: string; + first_name: string | null; + workos_organization_id: string; + }>; + + if (send_to_all) { + // Get all website-only users who haven't been sent an invite + const result = await pool.query<{ + workos_user_id: string; + email: string; + first_name: string | null; + workos_organization_id: string; + }>(` + SELECT DISTINCT ON (om.workos_user_id) + om.workos_user_id, + om.email, + om.first_name, + om.workos_organization_id + FROM organization_memberships om + LEFT JOIN slack_user_mappings sm_linked ON sm_linked.workos_user_id = om.workos_user_id + LEFT JOIN slack_user_mappings sm_email ON LOWER(sm_email.slack_email) = LOWER(om.email) + WHERE sm_linked.workos_user_id IS NULL + AND sm_email.slack_user_id IS NULL + AND NOT EXISTS ( + SELECT 1 FROM email_events ee + WHERE ee.workos_user_id = om.workos_user_id + AND ee.email_type = 'slack_invite' + AND ee.sent_at IS NOT NULL + ) + ORDER BY om.workos_user_id + `); + targetUsers = result.rows; + } else if (Array.isArray(user_ids) && user_ids.length > 0) { + // Get specific users + const result = await pool.query<{ + workos_user_id: string; + email: string; + first_name: string | null; + workos_organization_id: string; + }>(` + SELECT DISTINCT ON (om.workos_user_id) + om.workos_user_id, + om.email, + om.first_name, + om.workos_organization_id + FROM organization_memberships om + WHERE om.workos_user_id = ANY($1) + ORDER BY om.workos_user_id + `, [user_ids]); + targetUsers = result.rows; + } else { + return res.status(400).json({ + error: 'Invalid request', + message: 'Either send_to_all: true or user_ids array is required', + }); + } + + let sent = 0; + let skipped = 0; + let failed = 0; + const errors: string[] = []; + + for (const user of targetUsers) { + try { + const success = await sendSlackInviteEmail({ + to: user.email, + firstName: user.first_name || undefined, + workosUserId: user.workos_user_id, + workosOrganizationId: user.workos_organization_id, + }); + + if (success) { + // Check if it was actually sent or skipped (already sent) + const wasPreviouslySent = await hasSlackInviteBeenSent(user.workos_user_id); + if (wasPreviouslySent) { + skipped++; + } else { + sent++; + } + } else { + failed++; + errors.push(`Failed to send to ${user.email}`); + } + } catch (error) { + failed++; + errors.push(`Error sending to ${user.email}: ${error instanceof Error ? error.message : 'Unknown'}`); + } + + // Small delay between sends to avoid rate limiting + if (targetUsers.length > 1) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + logger.info({ sent, skipped, failed, total: targetUsers.length }, 'Slack invite emails batch complete'); + + res.json({ + sent, + skipped, + failed, + total: targetUsers.length, + errors: errors.length > 0 ? errors.slice(0, 10) : undefined, // Limit error list + }); + } catch (error) { + logger.error({ err: error }, 'Send Slack invites error'); + res.status(500).json({ + error: 'Failed to send Slack invites', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + return router; } diff --git a/server/src/routes/workos-webhooks.ts b/server/src/routes/workos-webhooks.ts index ee3a71057f..6e96f5167d 100644 --- a/server/src/routes/workos-webhooks.ts +++ b/server/src/routes/workos-webhooks.ts @@ -24,6 +24,7 @@ import { createLogger } from '../logger.js'; import { getPool } from '../db/client.js'; import { workos } from '../auth/workos-client.js'; import { invalidateUnifiedUsersCache } from '../cache/unified-users.js'; +import { tryAutoLinkWebsiteUserToSlack } from '../slack/sync.js'; const logger = createLogger('workos-webhooks'); @@ -644,7 +645,28 @@ export function createWorkOSWebhooksRouter(): Router { const event = req.body as WorkOSWebhookEvent; switch (event.event) { - case 'organization_membership.created': + case 'organization_membership.created': { + const membership = event.data as unknown as OrganizationMembershipData; + await upsertMembership(membership); + // Try to auto-link to Slack account by email (in case user.created didn't catch it) + if (membership.status === 'active') { + try { + const workosUser = await workos.userManagement.getUser(membership.user_id); + const linkResult = await tryAutoLinkWebsiteUserToSlack(membership.user_id, workosUser.email); + if (linkResult.linked) { + logger.info( + { userId: membership.user_id, email: workosUser.email, slackUserId: linkResult.slack_user_id }, + 'Auto-linked website user to Slack account on membership creation' + ); + } + } catch (error) { + logger.debug({ error, userId: membership.user_id }, 'Could not fetch user for auto-link on membership'); + } + } + invalidateUnifiedUsersCache(); + break; + } + case 'organization_membership.updated': { const membership = event.data as unknown as OrganizationMembershipData; await upsertMembership(membership); @@ -662,6 +684,14 @@ export function createWorkOSWebhooksRouter(): Router { case 'user.created': { const user = event.data as unknown as UserData; await upsertUser(user); + // Try to auto-link to Slack account by email + const linkResult = await tryAutoLinkWebsiteUserToSlack(user.id, user.email); + if (linkResult.linked) { + logger.info( + { userId: user.id, email: user.email, slackUserId: linkResult.slack_user_id }, + 'Auto-linked new website user to Slack account' + ); + } invalidateUnifiedUsersCache(); break; } diff --git a/server/src/slack/sync.ts b/server/src/slack/sync.ts index fd32bfdd5c..b403fc434e 100644 --- a/server/src/slack/sync.ts +++ b/server/src/slack/sync.ts @@ -1,15 +1,18 @@ /** * Slack user sync service * - * Fetches users from Slack workspace and syncs them to the database. - * Auto-mapping by email is handled separately via the admin API endpoint - * which has access to WorkOS user data. + * Handles syncing between Slack and website accounts: + * - Fetches users from Slack workspace and syncs them to the database + * - Auto-links accounts by email when users join (either direction) + * - Syncs working group memberships based on Slack channel membership */ import { logger } from '../logger.js'; import { getSlackUsers, getChannelMembers, getUserChannels, isSlackConfigured } from './client.js'; import { SlackDatabase } from '../db/slack-db.js'; import { WorkingGroupDatabase } from '../db/working-group-db.js'; +import { invalidateUnifiedUsersCache } from '../cache/unified-users.js'; +import { invalidateMemberContextCache } from '../addie/index.js'; import type { SyncSlackUsersResult } from './types.js'; const slackDb = new SlackDatabase(); @@ -71,6 +74,7 @@ export async function syncSlackUsers(): Promise<SyncSlackUsersResult> { slack_real_name: realName, slack_is_bot: user.is_bot, slack_is_deleted: user.deleted, + slack_tz_offset: user.tz_offset ?? null, }); result.total_synced++; @@ -87,8 +91,12 @@ export async function syncSlackUsers(): Promise<SyncSlackUsersResult> { } } - // Note: auto_mapped will remain 0 here. - // Use POST /api/admin/slack/auto-link-suggested after sync to auto-map by email. + // Note: auto_mapped will remain 0 here since bulk sync doesn't auto-map. + // Auto-mapping happens on: + // - team_join event (Slack user joins workspace) + // - user.created webhook (website user signs up) + // - organization_membership.created webhook (user joins org) + // For historical users, use POST /api/admin/slack/auto-link-suggested logger.info(result, 'Slack user sync completed'); return result; } catch (error) { @@ -419,3 +427,108 @@ export async function syncUserToChaptersFromSlackChannels( return result; } } + +// ============== Auto-Link by Email ============== + +export interface AutoLinkResult { + linked: boolean; + slack_user_id?: string; + workos_user_id?: string; + chapters_joined?: number; + reason?: string; +} + +/** + * Try to auto-link a website user to their Slack account by email + * + * Called when a new user signs up on the website. Looks for a Slack user + * with the same email and links them if found. + */ +export async function tryAutoLinkWebsiteUserToSlack( + workosUserId: string, + email: string +): Promise<AutoLinkResult> { + try { + // Find Slack user with this email + const slackUser = await slackDb.findByEmail(email); + + if (!slackUser) { + logger.debug({ email }, 'No Slack user found for website user email'); + return { linked: false, reason: 'no_slack_user' }; + } + + // Check if Slack user is already mapped to a different WorkOS user + if (slackUser.workos_user_id && slackUser.workos_user_id !== workosUserId) { + logger.debug( + { email, existingWorkosUserId: slackUser.workos_user_id, newWorkosUserId: workosUserId }, + 'Slack user already mapped to different WorkOS account' + ); + return { linked: false, reason: 'slack_user_already_mapped' }; + } + + // Check if this WorkOS user is already mapped to a different Slack user + const existingMapping = await slackDb.getByWorkosUserId(workosUserId); + if (existingMapping && existingMapping.slack_user_id !== slackUser.slack_user_id) { + logger.debug( + { workosUserId, existingSlackUserId: existingMapping.slack_user_id }, + 'WorkOS user already mapped to different Slack account' + ); + return { linked: false, reason: 'workos_user_already_mapped' }; + } + + // Already linked (same accounts) + if (slackUser.workos_user_id === workosUserId) { + logger.debug({ workosUserId, slackUserId: slackUser.slack_user_id }, 'Users already linked'); + return { linked: false, reason: 'already_linked' }; + } + + // Skip bots and deleted users + if (slackUser.slack_is_bot) { + return { linked: false, reason: 'slack_user_is_bot' }; + } + if (slackUser.slack_is_deleted) { + return { linked: false, reason: 'slack_user_is_deleted' }; + } + + // Link the accounts + await slackDb.mapUser({ + slack_user_id: slackUser.slack_user_id, + workos_user_id: workosUserId, + mapping_source: 'email_auto', + }); + + logger.info( + { workosUserId, slackUserId: slackUser.slack_user_id, email }, + 'Auto-linked website user to Slack account by email' + ); + + // Sync user to chapters based on their Slack channel memberships + let chaptersJoined = 0; + try { + const chapterSyncResult = await syncUserToChaptersFromSlackChannels(workosUserId, slackUser.slack_user_id); + chaptersJoined = chapterSyncResult.chapters_joined; + if (chaptersJoined > 0) { + logger.info( + { workosUserId, chaptersJoined }, + 'Auto-synced user to chapters from Slack channels' + ); + } + } catch (error) { + logger.error({ error, workosUserId }, 'Failed to sync chapters after auto-link'); + } + + // Invalidate caches + invalidateUnifiedUsersCache(); + invalidateMemberContextCache(slackUser.slack_user_id); + + return { + linked: true, + slack_user_id: slackUser.slack_user_id, + workos_user_id: workosUserId, + chapters_joined: chaptersJoined, + }; + } catch (error) { + logger.error({ error, workosUserId, email }, 'Failed to auto-link website user to Slack'); + return { linked: false, reason: 'error' }; + } +} diff --git a/server/src/slack/types.ts b/server/src/slack/types.ts index b0466698cb..21adec0a45 100644 --- a/server/src/slack/types.ts +++ b/server/src/slack/types.ts @@ -30,6 +30,8 @@ export interface SlackUserMapping { last_outreach_at: Date | null; outreach_opt_out: boolean; outreach_opt_out_at: Date | null; + // Timezone offset in seconds from UTC (from Slack) + slack_tz_offset: number | null; created_at: Date; updated_at: Date; } From 6fbb9fe8551fec6928d474a4de3d8273320af7ca Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 10:53:11 -0500 Subject: [PATCH 28/77] fix: correct Addie GitHub issue drafting repo and add confidentiality guidelines (#703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove references to non-existent aao-server repo, direct all issues to adcp - Add CRITICAL confidentiality rules to prevent PII in public GitHub issues - Add requirement to always include actual error messages in bug reports - Update ecosystem docs to reflect consolidated repository structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/bright-kids-say.md | 4 ++ docs/reference/adcp-ecosystem.mdx | 5 +- server/src/addie/mcp/member-tools.ts | 6 +- .../migrations/155_fix_github_issue_repo.sql | 64 +++++++++++++++++++ v2.6-rc/docs/reference/adcp-ecosystem.mdx | 5 +- 5 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 .changeset/bright-kids-say.md create mode 100644 server/src/db/migrations/155_fix_github_issue_repo.sql 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/docs/reference/adcp-ecosystem.mdx b/docs/reference/adcp-ecosystem.mdx index 261bc9f05b..680fd67588 100644 --- a/docs/reference/adcp-ecosystem.mdx +++ b/docs/reference/adcp-ecosystem.mdx @@ -23,10 +23,9 @@ All repos are under the `adcontextprotocol` GitHub organization. | Repo | Purpose | Key Contents | |------|---------|--------------| -| **adcp** | Core protocol specification | JSON schemas, TypeScript SDK (`@adcp/client`), Python SDK (`adcp`), protocol docs | +| **adcp** | Protocol and AgenticAdvertising.org | JSON schemas, TypeScript/Python SDKs, protocol docs, AgenticAdvertising.org server (Addie AI, membership, profiles) | | **salesagent** | Reference sales agent implementation | Sales agent code, salesagent docs, deployment configs | | **creative-agent** | Reference creative agent | Creative workflows, standard formats, format validation | -| **aao-server** | AgenticAdvertising.org website | Community features, membership, Addie AI assistant, member profiles | ## Channel-to-Repo Mapping @@ -37,7 +36,7 @@ For Slack channels → GitHub issues: | `#salesagent-*` | salesagent | Sales agent implementation and docs | | `#creative-*` | creative-agent | Creative agent and formats | | `#adcp-dev`, `#protocol-*` | adcp | Core protocol and schemas | -| `#general`, `#community`, `#membership` | aao-server | Community and website | +| `#general`, `#community`, `#membership` | adcp | AgenticAdvertising.org server and community features | ## SDK Packages diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 977cde1bfe..c0f35ddc32 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -725,7 +725,7 @@ export const MEMBER_TOOLS: AddieTool[] = [ { name: 'draft_github_issue', description: - 'Draft a GitHub issue and generate a pre-filled URL for the user to create it. Use this when users report bugs, request features, or ask you to create a GitHub issue. CRITICAL: Users CANNOT see tool outputs - you MUST copy this tool\'s entire output (the GitHub link, title, body preview) into your response. Never say "click the link above" without including the actual link. The user will click the link to create the issue from their own GitHub account. Infer the appropriate repo from context (channel name, conversation topic) - use "adcp" for protocol/docs issues, "aao-server" for website/community issues.', + 'Draft a GitHub issue and generate a pre-filled URL for the user to create it. Use this when users report bugs, request features, or ask you to create a GitHub issue. CRITICAL: Users CANNOT see tool outputs - you MUST copy this tool\'s entire output (the GitHub link, title, body preview) into your response. Never say "click the link above" without including the actual link. The user will click the link to create the issue from their own GitHub account. All issues go to the "adcp" repository which contains the protocol, schemas, AgenticAdvertising.org server, and documentation.', usage_hints: 'use when user wants to report a bug or request a feature - MUST include full output in response', input_schema: { type: 'object', @@ -737,12 +737,12 @@ export const MEMBER_TOOLS: AddieTool[] = [ body: { type: 'string', description: - 'Issue body in markdown format. Include context, steps to reproduce (for bugs), or detailed description (for features). Reference the Slack conversation if relevant.', + 'Issue body in markdown format. CRITICAL: Never include customer names, emails, org IDs, or any PII - GitHub issues are public. Use generic placeholders like [Customer] or [Organization]. For bugs, ALWAYS include the exact error message. Include anonymized steps to reproduce.', }, repo: { type: 'string', description: - 'Repository name within adcontextprotocol org (e.g., "adcp" for protocol/docs, "aao-server" for website/community). Default: "adcp"', + 'Repository name within adcontextprotocol org. Always use "adcp" - it contains the protocol, schemas, server, and docs. Default: "adcp"', }, labels: { type: 'array', diff --git a/server/src/db/migrations/155_fix_github_issue_repo.sql b/server/src/db/migrations/155_fix_github_issue_repo.sql new file mode 100644 index 0000000000..dbb4d76863 --- /dev/null +++ b/server/src/db/migrations/155_fix_github_issue_repo.sql @@ -0,0 +1,64 @@ +-- Fix GitHub Issue Drafting rule +-- +-- Problems: +-- 1. The rule tells Addie to create issues in the "aao-server" repo, but that +-- repository doesn't exist. All code is in the main "adcp" repository. +-- 2. Addie includes confidential customer information in issue bodies +-- 3. Addie doesn't include the actual error messages +-- +-- Solution: Update the rule with correct repo and privacy guidelines. + +-- Update the existing GitHub Issue Drafting rule +UPDATE addie_rules +SET content = 'You have a draft_github_issue tool to help users create GitHub issues for bugs or feature requests. When users: +- Report a bug or broken link +- Request a feature or enhancement +- Ask you to create a GitHub issue +- Discuss something that should be tracked + +Use draft_github_issue to generate a pre-filled GitHub URL. + +**CRITICAL - CONFIDENTIALITY**: GitHub issues are PUBLIC. NEVER include: +- Customer/company names (use "[Customer]" or "[Organization]" instead) +- Email addresses or contact information +- Organization IDs, user IDs, or other identifiers +- Billing amounts, discounts, or financial details +- Any personally identifiable information (PII) + +**CRITICAL - ERROR DETAILS**: For bug reports, ALWAYS include: +- The exact error message (if any was returned) +- The tool name and parameters that caused the error (sanitized of PII) +- What the expected behavior was vs what actually happened + +**CRITICAL - TOOL OUTPUT VISIBILITY**: Users CANNOT see tool outputs directly. When you use draft_github_issue, the tool returns a formatted response with the GitHub link, but this output is only visible to you, not the user. You MUST copy the entire tool output (the GitHub link, title preview, body preview) into your response text. + +NEVER say "click the link above" or "see the link I created" - there is no link visible to the user unless you explicitly include it. Always format your response like: + +"I''ve drafted a GitHub issue for you: + +**[Create Issue on GitHub](https://github.com/...)** + +**Title:** [the title] +**Body preview:** [summary of the body]" + +**adcontextprotocol organization repos:** +- "adcp" - Main repository containing: protocol specification, JSON schemas, TypeScript/Python SDKs, AgenticAdvertising.org server (Addie AI, membership, community features) +- "salesagent" - Reference sales agent implementation, salesagent docs +- "creative-agent" - Reference creative agent, standard formats, creative workflow + +**Channel → Repo hints:** +- #salesagent-users, #salesagent-dev → salesagent repo +- #creative-agent, #creative-formats → creative-agent repo +- All other channels → adcp repo (protocol, website, community features, Addie bugs) + +Draft clear, actionable issues with: +- Descriptive title summarizing the issue (no customer names) +- Generic description of the scenario (anonymized) +- The exact error message or unexpected behavior +- Steps to reproduce (with sanitized/generic data) +- Appropriate labels (bug, enhancement, documentation, etc.) + +You can proactively offer to draft issues when you notice problems being discussed.', + updated_at = NOW() +WHERE name = 'GitHub Issue Drafting' + AND rule_type = 'behavior'; diff --git a/v2.6-rc/docs/reference/adcp-ecosystem.mdx b/v2.6-rc/docs/reference/adcp-ecosystem.mdx index 261bc9f05b..680fd67588 100644 --- a/v2.6-rc/docs/reference/adcp-ecosystem.mdx +++ b/v2.6-rc/docs/reference/adcp-ecosystem.mdx @@ -23,10 +23,9 @@ All repos are under the `adcontextprotocol` GitHub organization. | Repo | Purpose | Key Contents | |------|---------|--------------| -| **adcp** | Core protocol specification | JSON schemas, TypeScript SDK (`@adcp/client`), Python SDK (`adcp`), protocol docs | +| **adcp** | Protocol and AgenticAdvertising.org | JSON schemas, TypeScript/Python SDKs, protocol docs, AgenticAdvertising.org server (Addie AI, membership, profiles) | | **salesagent** | Reference sales agent implementation | Sales agent code, salesagent docs, deployment configs | | **creative-agent** | Reference creative agent | Creative workflows, standard formats, format validation | -| **aao-server** | AgenticAdvertising.org website | Community features, membership, Addie AI assistant, member profiles | ## Channel-to-Repo Mapping @@ -37,7 +36,7 @@ For Slack channels → GitHub issues: | `#salesagent-*` | salesagent | Sales agent implementation and docs | | `#creative-*` | creative-agent | Creative agent and formats | | `#adcp-dev`, `#protocol-*` | adcp | Core protocol and schemas | -| `#general`, `#community`, `#membership` | aao-server | Community and website | +| `#general`, `#community`, `#membership` | adcp | AgenticAdvertising.org server and community features | ## SDK Packages From f482d4e346ae34506be5944c7786980765b4efcb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 11:21:30 -0500 Subject: [PATCH 29/77] fix: filter short org names from Similar Organizations matching (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: filter short org names from Similar Organizations matching Add length filter for both sides of the substring comparison in Similar Organizations queries. Previously, short org names (like "HP", "LG", "UM") could match as substrings within longer names, causing false positive duplicate suggestions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: use trigram similarity for Similar Organizations matching Replace substring matching with pg_trgm trigram similarity for more accurate duplicate detection. This prevents false positives like "Jasper" and "Casper" which share letters but are distinct companies. - Add migration to enable pg_trgm extension and create GIN index - Use similarity() function with 0.4 threshold for fuzzy matching - Sort results by similarity score (best matches first) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add prefix/suffix matching to Similar Organizations Combine trigram similarity with prefix/suffix matching for better duplicate detection: - Prefix/suffix catches "Yahoo" vs "Yahoo Inc", "The Trade Desk" vs "Trade Desk" - Trigram catches typos like "Gooogle" vs "Google" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: rename migration to avoid duplicate version number Renamed 155_pg_trgm_extension.sql to 156_pg_trgm_extension.sql since main already has 155_fix_github_issue_repo.sql. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/twenty-bobcats-fall.md | 2 ++ .../src/db/migrations/156_pg_trgm_extension.sql | 8 ++++++++ server/src/routes/admin/accounts.ts | 17 +++++++++++++---- server/src/routes/admin/domains.ts | 15 ++++++++++----- 4 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 .changeset/twenty-bobcats-fall.md create mode 100644 server/src/db/migrations/156_pg_trgm_extension.sql 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/server/src/db/migrations/156_pg_trgm_extension.sql b/server/src/db/migrations/156_pg_trgm_extension.sql new file mode 100644 index 0000000000..511567f23d --- /dev/null +++ b/server/src/db/migrations/156_pg_trgm_extension.sql @@ -0,0 +1,8 @@ +-- Enable pg_trgm extension for trigram similarity matching +-- Used for finding similar organization names with typo tolerance + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Create index on organizations.name for faster similarity searches +CREATE INDEX IF NOT EXISTS idx_organizations_name_trgm +ON organizations USING gin (name gin_trgm_ops); diff --git a/server/src/routes/admin/accounts.ts b/server/src/routes/admin/accounts.ts index bad4bcb38b..e9526d46f2 100644 --- a/server/src/routes/admin/accounts.ts +++ b/server/src/routes/admin/accounts.ts @@ -481,6 +481,7 @@ export function setupAccountRoutes( ), // Domain health: Similar organization names (potential duplicates) + // Uses pg_trgm trigram similarity for fuzzy matching // Skip for personal workspaces - they shouldn't have duplicates pool.query( ` @@ -513,16 +514,24 @@ export function setupAccountRoutes( oo.workos_organization_id as org_id, oo.name, oo.subscription_status, - oo.normalized_name + oo.normalized_name, + similarity(oo.normalized_name, t.normalized_name) as match_score FROM this_org t JOIN other_orgs oo ON ( + -- Exact match on normalized name oo.normalized_name = t.normalized_name - OR oo.normalized_name LIKE '%' || t.normalized_name || '%' - OR t.normalized_name LIKE '%' || oo.normalized_name || '%' + -- Or one is a prefix/suffix of the other (e.g., "Yahoo" vs "Yahoo Inc") + OR oo.normalized_name LIKE t.normalized_name || '%' + OR oo.normalized_name LIKE '%' || t.normalized_name + OR t.normalized_name LIKE oo.normalized_name || '%' + OR t.normalized_name LIKE '%' || oo.normalized_name + -- Or high trigram similarity (0.4+ catches typos and variations) + OR similarity(oo.normalized_name, t.normalized_name) >= 0.4 ) WHERE LENGTH(t.normalized_name) >= 3 + AND LENGTH(oo.normalized_name) >= 3 AND t.is_personal = false - ORDER BY oo.name ASC + ORDER BY similarity(oo.normalized_name, t.normalized_name) DESC, oo.name ASC `, [orgId] ), diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 9cf940f751..647200a640 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -1472,7 +1472,7 @@ export function setupDomainRoutes( `, [...allExcludedDomains, limit]); // 7. Similar organization names - find orgs with similar names that might be duplicates - // Uses trigram similarity (requires pg_trgm extension) or simple word matching + // Uses pg_trgm trigram similarity for fuzzy matching const similarNamesResult = await pool.query(` WITH org_names AS ( SELECT @@ -1502,11 +1502,16 @@ export function setupDomainRoutes( AND ( -- Exact match on normalized name o1.normalized_name = o2.normalized_name - -- Or one is substring of the other (e.g., "Yahoo" vs "Yahoo Inc") - OR o1.normalized_name LIKE '%' || o2.normalized_name || '%' - OR o2.normalized_name LIKE '%' || o1.normalized_name || '%' + -- Or one is a prefix/suffix of the other (e.g., "Yahoo" vs "Yahoo Inc") + OR o1.normalized_name LIKE o2.normalized_name || '%' + OR o1.normalized_name LIKE '%' || o2.normalized_name + OR o2.normalized_name LIKE o1.normalized_name || '%' + OR o2.normalized_name LIKE '%' || o1.normalized_name + -- Or high trigram similarity (0.4+ catches typos and variations) + OR similarity(o1.normalized_name, o2.normalized_name) >= 0.4 ) - WHERE LENGTH(o1.normalized_name) >= 3 -- Avoid matching very short names + WHERE LENGTH(o1.normalized_name) >= 3 + AND LENGTH(o2.normalized_name) >= 3 GROUP BY o1.normalized_name HAVING COUNT(DISTINCT o1.workos_organization_id) > 1 ) From 25675bef7910ce1f3d1a6130deb2a5ee7f345e6a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 11:43:36 -0500 Subject: [PATCH 30/77] fix: reduce production log noise and improve Stripe billing (#705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quiet migration output when no pending migrations - Change startup job logs from info to debug level - Handle Stripe customer sync conflicts gracefully (no duplicate key errors) - Add Stripe conflict detection API for admin resolution - Integrate Stripe conflicts UI into billing page (removes separate page) - Auto-apply org discounts in send_invoice tool - Validate coupon IDs before Stripe API calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/small-geese-leave.md | 2 + server/public/admin-billing.html | 240 ++++++++++++++++++++++++++ server/src/addie/mcp/admin-tools.ts | 7 +- server/src/addie/mcp/billing-tools.ts | 62 ++++++- server/src/billing/stripe-client.ts | 38 +++- server/src/db/migrate.ts | 7 +- server/src/db/organization-db.ts | 172 ++++++++++++++---- server/src/http.ts | 27 ++- server/src/routes/billing.ts | 192 +++++++++++++++++++++ 9 files changed, 681 insertions(+), 66 deletions(-) create mode 100644 .changeset/small-geese-leave.md 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/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); + } </style> </head> <body> @@ -302,6 +367,32 @@ <h1>Stripe Customer Linking</h1> <div class="stat-label">Unlinked with Payments</div> <div class="stat-value warning" id="stat-unlinked-payments">-</div> </div> + <div class="stat-card" id="conflicts-stat-card" style="display: none;"> + <div class="stat-label">Conflicts</div> + <div class="stat-value error" id="stat-conflicts">0</div> + </div> + </div> + + <!-- Conflicts Section (only shown when conflicts exist) --> + <div class="card conflicts-card" id="conflicts-section" style="display: none;"> + <div class="card-header"> + <div class="card-title">Stripe Metadata Conflicts</div> + </div> + <div class="conflict-info-box"> + A conflict occurs when a Stripe customer's metadata says it belongs to Organization A, but our database has it linked to Organization B. Choose which organization should own the customer. + </div> + <table class="conflicts-table"> + <thead> + <tr> + <th>Stripe Customer</th> + <th>Stripe Says (metadata)</th> + <th>Database Has</th> + <th>Actions</th> + </tr> + </thead> + <tbody id="conflicts-body"> + </tbody> + </table> </div> <div class="card"> @@ -340,6 +431,7 @@ <h1>Stripe Customer Linking</h1> <script> let allCustomers = []; + let allConflicts = []; let currentFilter = 'all'; const formatCurrency = (cents) => { @@ -613,6 +705,153 @@ <h1>Stripe Customer Linking</h1> document.getElementById('stat-linked').textContent = linked; document.getElementById('stat-unlinked').textContent = unlinked; document.getElementById('stat-unlinked-payments').textContent = unlinkedWithPayments; + + // Update conflicts stat + const conflictsStatCard = document.getElementById('conflicts-stat-card'); + const conflictsSection = document.getElementById('conflicts-section'); + if (allConflicts.length > 0) { + document.getElementById('stat-conflicts').textContent = allConflicts.length; + conflictsStatCard.style.display = 'block'; + conflictsSection.style.display = 'block'; + } else { + conflictsStatCard.style.display = 'none'; + conflictsSection.style.display = 'none'; + } + } + + function renderConflicts() { + const tbody = document.getElementById('conflicts-body'); + tbody.innerHTML = allConflicts.map((conflict, index) => ` + <tr data-index="${index}"> + <td> + <div><strong>${conflict.stripe_customer_id}</strong></div> + <div style="font-size: 11px;"> + <a href="https://dashboard.stripe.com/customers/${conflict.stripe_customer_id}" target="_blank" rel="noopener" style="color: var(--color-brand);"> + View in Stripe → + </a> + </div> + </td> + <td> + <div class="conflict-choice"> + <div class="conflict-choice-info"> + <div class="conflict-choice-name">${conflict.stripe_says_org_name || 'Unknown'}</div> + <div class="conflict-choice-id">${conflict.stripe_says_org_id}</div> + <span class="badge badge-gray">From Stripe metadata</span> + </div> + <button class="btn btn-primary btn-sm" onclick="resolveConflict(${index}, 'stripe')" title="Use Stripe's metadata as source of truth"> + Use This + </button> + </div> + </td> + <td> + <div class="conflict-choice"> + <div class="conflict-choice-info"> + <div class="conflict-choice-name">${conflict.db_has_org_name}</div> + <div class="conflict-choice-id">${conflict.db_has_org_id}</div> + <span class="badge badge-warning">Currently in database</span> + </div> + <button class="btn btn-secondary btn-sm" onclick="resolveConflict(${index}, 'database')" title="Keep database record, update Stripe metadata"> + Keep This + </button> + </div> + </td> + <td> + <button class="btn btn-danger btn-sm" onclick="unlinkConflict(${index})" title="Unlink from both and resolve manually"> + Unlink + </button> + </td> + </tr> + `).join(''); + } + + async function resolveConflict(index, choice) { + const conflict = allConflicts[index]; + const keepOrgId = choice === 'stripe' ? conflict.stripe_says_org_id : conflict.db_has_org_id; + const keepOrgName = choice === 'stripe' ? conflict.stripe_says_org_name : conflict.db_has_org_name; + const action = choice === 'stripe' ? 'unlink_other' : 'update_stripe_metadata'; + + if (!confirm(`Link Stripe customer ${conflict.stripe_customer_id} to "${keepOrgName}"?`)) { + return; + } + + try { + const response = await fetch('/api/admin/stripe-conflicts/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stripe_customer_id: conflict.stripe_customer_id, + keep_org_id: keepOrgId, + action: action, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to resolve conflict'); + } + + showSuccess(`Resolved: ${conflict.stripe_customer_id} is now linked to "${keepOrgName}"`); + + // Remove from list and re-render + allConflicts.splice(index, 1); + updateStats(); + renderConflicts(); + } catch (err) { + showError(err.message); + } + } + + async function unlinkConflict(index) { + const conflict = allConflicts[index]; + + if (!confirm(`Unlink Stripe customer ${conflict.stripe_customer_id} from "${conflict.db_has_org_name}"? You can then manually link it to the correct organization.`)) { + return; + } + + try { + const response = await fetch('/api/admin/stripe-customer/unlink', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + org_id: conflict.db_has_org_id, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to unlink customer'); + } + + showSuccess(`Unlinked ${conflict.stripe_customer_id}. You can now link it manually below.`); + + // Remove from list, refresh customers, and re-render + allConflicts.splice(index, 1); + updateStats(); + renderConflicts(); + loadCustomers(); // Refresh customers list to show the now-unlinked customer + } catch (err) { + showError(err.message); + } + } + + async function loadConflicts() { + try { + const response = await fetch('/api/admin/stripe-conflicts'); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to load conflicts'); + } + + allConflicts = data.conflicts || []; + updateStats(); + renderConflicts(); + } catch (err) { + console.error('Failed to load conflicts:', err); + // Don't show error to user - conflicts are secondary to the main customer list + } } async function loadCustomers() { @@ -647,6 +886,7 @@ <h1>Stripe Customer Linking</h1> // Load on page load loadCustomers(); + loadConflicts(); </script> </body> </html> diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index d9df7501ec..f57f763a5e 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -3108,7 +3108,7 @@ export function createAdminToolHandlers( if (finalProduct.amount_cents) { const originalAmount = finalProduct.amount_cents / 100; response += `**Amount:** $${originalAmount.toLocaleString()}`; - if (appliedDiscount) { + if (appliedDiscount && invoiceResult.discountApplied) { // Calculate discounted amount for display let discountedAmount = originalAmount; if (discountPercent) { @@ -3131,6 +3131,11 @@ export function createAdminToolHandlers( } response += `\n_Stripe will email the invoice with a payment link. They have 30 days to pay._`; + // Warn if discount was requested but not applied + if (invoiceResult.discountWarning) { + response += `\n\n⚠️ **Warning:** ${invoiceResult.discountWarning}`; + } + logger.info( { orgId: org.workos_organization_id, orgName: org.name, invoiceId: invoiceResult.invoiceId, discount: appliedDiscount }, 'Addie sent invoice' diff --git a/server/src/addie/mcp/billing-tools.ts b/server/src/addie/mcp/billing-tools.ts index 4fc1628482..588a3450de 100644 --- a/server/src/addie/mcp/billing-tools.ts +++ b/server/src/addie/mcp/billing-tools.ts @@ -16,8 +16,10 @@ import { getPriceByLookupKey, type BillingProduct, } from '../../billing/stripe-client.js'; +import { OrganizationDatabase } from '../../db/organization-db.js'; const logger = createLogger('addie-billing-tools'); +const orgDb = new OrganizationDatabase(); /** * Tool definitions for billing operations @@ -70,7 +72,8 @@ Returns a URL the user can click to complete payment.`, description: `Send an invoice for a membership product to a customer. Use this when the customer needs to pay via invoice/PO instead of credit card. Requires full billing information including address. -To apply a discount, first call grant_discount to create a coupon, then pass the coupon_id here.`, +If the organization has a discount on file (from grant_discount), it will be automatically applied. +You can also pass an explicit coupon_id to override.`, input_schema: { type: 'object' as const, properties: { @@ -105,7 +108,7 @@ To apply a discount, first call grant_discount to create a coupon, then pass the }, coupon_id: { type: 'string', - description: 'Stripe coupon ID to apply a discount (get this from grant_discount tool)', + description: 'Explicit Stripe coupon ID to apply (optional - org discount is used automatically if available)', }, }, required: ['lookup_key', 'company_name', 'contact_name', 'contact_email', 'billing_address'], @@ -269,10 +272,41 @@ export function createBillingToolHandlers(): Map<string, (input: Record<string, postal_code: string; country: string; }; - const couponId = input.coupon_id as string | undefined; + const explicitCouponId = input.coupon_id as string | undefined; + + // Try to find organization by name to get stored discount + let effectiveCouponId = explicitCouponId; + let orgDiscount: string | undefined; + let workosOrgId: string | undefined; + + try { + const orgs = await orgDb.searchOrganizations({ query: companyName, limit: 1 }); + if (orgs.length > 0) { + const org = await orgDb.getOrganization(orgs[0].workos_organization_id); + if (org) { + workosOrgId = org.workos_organization_id; + // Use org's stored coupon if no explicit coupon provided + if (!explicitCouponId && org.stripe_coupon_id) { + effectiveCouponId = org.stripe_coupon_id; + orgDiscount = org.discount_percent + ? `${org.discount_percent}% off` + : org.discount_amount_cents + ? `$${org.discount_amount_cents / 100} off` + : undefined; + logger.info( + { orgId: org.workos_organization_id, orgName: org.name, couponId: effectiveCouponId, discount: orgDiscount }, + 'Addie: Using organization stored discount for invoice' + ); + } + } + } + } catch (orgLookupError) { + // Non-fatal - continue without org lookup + logger.debug({ error: orgLookupError, companyName }, 'Could not look up organization for discount'); + } logger.info( - { lookupKey, contactEmail, companyName, hasCoupon: !!couponId }, + { lookupKey, contactEmail, companyName, hasCoupon: !!effectiveCouponId, usingOrgDiscount: !!orgDiscount }, 'Addie: Sending invoice' ); @@ -283,7 +317,8 @@ export function createBillingToolHandlers(): Map<string, (input: Record<string, contactName, contactEmail, billingAddress, - couponId, + couponId: effectiveCouponId, + workosOrganizationId: workosOrgId, }); if (!result) { @@ -293,11 +328,26 @@ export function createBillingToolHandlers(): Map<string, (input: Record<string, }); } + // Build response message + let message = `Invoice sent to ${contactEmail}. They will receive an email with payment instructions.`; + if (result.discountWarning) { + message += `\n\n⚠️ WARNING: ${result.discountWarning}`; + } else if (result.discountApplied) { + if (orgDiscount) { + message += `\n\n✅ Organization discount applied: ${orgDiscount}`; + } else { + message += `\n\n✅ Discount applied successfully.`; + } + } + return JSON.stringify({ success: true, invoice_id: result.invoiceId, invoice_url: result.invoiceUrl, - message: `Invoice sent to ${contactEmail}. They will receive an email with payment instructions.`, + discount_applied: result.discountApplied, + discount_description: orgDiscount, + discount_warning: result.discountWarning, + message, }); } catch (error) { logger.error({ error }, 'Addie: Error sending invoice'); diff --git a/server/src/billing/stripe-client.ts b/server/src/billing/stripe-client.ts index 91eb8f72e4..6b8e44f029 100644 --- a/server/src/billing/stripe-client.ts +++ b/server/src/billing/stripe-client.ts @@ -795,7 +795,7 @@ export interface InvoiceRequestData { */ export async function createAndSendInvoice( data: InvoiceRequestData -): Promise<{ invoiceId: string; invoiceUrl: string; subscriptionId: string } | null> { +): Promise<{ invoiceId: string; invoiceUrl: string; subscriptionId: string; discountApplied: boolean; discountWarning?: string } | null> { if (!stripe) { logger.warn('Stripe not initialized - cannot create invoice'); return null; @@ -864,6 +864,36 @@ export async function createAndSendInvoice( currency: price.currency, }, 'createAndSendInvoice: Creating subscription with verified price'); + // Validate coupon exists if provided + let validatedCouponId: string | undefined; + let discountWarning: string | undefined; + if (data.couponId) { + try { + const coupon = await stripe.coupons.retrieve(data.couponId); + if (!coupon || !coupon.valid) { + discountWarning = `Coupon "${data.couponId}" is invalid or expired. Invoice sent without discount. Use grant_discount to create a valid coupon.`; + logger.warn({ + couponId: data.couponId, + valid: coupon?.valid, + }, 'createAndSendInvoice: Coupon is invalid or expired, proceeding without discount'); + } else { + validatedCouponId = data.couponId; + logger.info({ + couponId: data.couponId, + percentOff: coupon.percent_off, + amountOff: coupon.amount_off, + }, 'createAndSendInvoice: Validated coupon'); + } + } catch (couponError) { + // Coupon doesn't exist - log and proceed without it + discountWarning = `Coupon "${data.couponId}" does not exist in Stripe. Invoice sent without discount. Use grant_discount to create a valid coupon first.`; + logger.warn({ + couponId: data.couponId, + error: couponError instanceof Error ? couponError.message : 'Unknown error', + }, 'createAndSendInvoice: Coupon not found in Stripe, proceeding without discount'); + } + } + // Create subscription with invoice billing // This creates a subscription AND generates an invoice for the first payment // When the invoice is paid, the subscription becomes active and will auto-renew @@ -872,8 +902,8 @@ export async function createAndSendInvoice( items: [{ price: priceId }], collection_method: 'send_invoice', days_until_due: 30, - // Apply coupon if provided (for discounts) - ...(data.couponId && { discounts: [{ coupon: data.couponId }] }), + // Apply coupon if validated + ...(validatedCouponId && { discounts: [{ coupon: validatedCouponId }] }), metadata: { lookup_key: data.lookupKey, contact_name: data.contactName, @@ -927,6 +957,8 @@ export async function createAndSendInvoice( invoiceId: invoiceId, invoiceUrl: sentInvoice.hosted_invoice_url || '', subscriptionId: subscription.id, + discountApplied: !!validatedCouponId, + discountWarning, }; } catch (error) { const stripeError = error as { type?: string; code?: string; message?: string }; diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 999c557fa6..8c3481fb45 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -141,8 +141,6 @@ async function applyMigration(migration: Migration): Promise<void> { * Run all pending migrations */ export async function runMigrations(config?: DatabaseConfig): Promise<void> { - console.log("Running database migrations..."); - // Initialize database if config provided if (config) { initializeDatabase(config); @@ -155,16 +153,13 @@ export async function runMigrations(config?: DatabaseConfig): Promise<void> { const migrations = await loadMigrations(); const appliedVersions = await getAppliedMigrations(); - console.log(`Found ${migrations.length} migrations`); - console.log(`${appliedVersions.length} already applied`); - // Find pending migrations const pendingMigrations = migrations.filter( (m) => !appliedVersions.includes(m.version) ); if (pendingMigrations.length === 0) { - console.log("No pending migrations"); + // Quiet startup - no output when nothing to do return; } diff --git a/server/src/db/organization-db.ts b/server/src/db/organization-db.ts index e30361b0b1..ce4c25d5db 100644 --- a/server/src/db/organization-db.ts +++ b/server/src/db/organization-db.ts @@ -6,6 +6,24 @@ import { CompanyTypeValue } from '../config/company-types.js'; const logger = createLogger('organization-db'); +/** + * Error thrown when trying to link a Stripe customer that's already linked to another organization + */ +export class StripeCustomerConflictError extends Error { + constructor( + public stripeCustomerId: string, + public targetOrgId: string, + public existingOrgId: string, + public existingOrgName: string + ) { + super( + `Stripe customer ${stripeCustomerId} is already linked to organization "${existingOrgName}" (${existingOrgId}). ` + + `Cannot link to ${targetOrgId}. Use force option or resolve the conflict manually.` + ); + this.name = 'StripeCustomerConflictError'; + } +} + export type CompanyType = CompanyTypeValue; export type RevenueTier = 'under_1m' | '1m_5m' | '5m_50m' | '50m_250m' | '250m_1b' | '1b_plus'; @@ -401,12 +419,35 @@ export class OrganizationDatabase { // Billing Methods /** - * Set Stripe customer ID for an organization + * Set Stripe customer ID for an organization. + * Checks for conflicts before setting - throws StripeCustomerConflictError if customer is already linked to another org. + * @param options.force - If true, unlinks from existing org first (use with caution) */ async setStripeCustomerId( workos_organization_id: string, - stripe_customer_id: string + stripe_customer_id: string, + options?: { force?: boolean } ): Promise<void> { + // Check if this customer ID is already assigned to another org + const existingOrg = await this.getOrganizationByStripeCustomerId(stripe_customer_id); + if (existingOrg && existingOrg.workos_organization_id !== workos_organization_id) { + if (options?.force) { + // Unlink from existing org first + logger.warn( + { stripeCustomerId: stripe_customer_id, fromOrgId: existingOrg.workos_organization_id, toOrgId: workos_organization_id }, + 'Force-unlinking Stripe customer from existing organization' + ); + await this.unlinkStripeCustomer(existingOrg.workos_organization_id); + } else { + throw new StripeCustomerConflictError( + stripe_customer_id, + workos_organization_id, + existingOrg.workos_organization_id, + existingOrg.name + ); + } + } + const pool = getPool(); await pool.query( 'UPDATE organizations SET stripe_customer_id = $1, updated_at = NOW() WHERE workos_organization_id = $2', @@ -414,6 +455,57 @@ export class OrganizationDatabase { ); } + /** + * Unlink Stripe customer from an organization (set to null) + */ + async unlinkStripeCustomer(workos_organization_id: string): Promise<void> { + const pool = getPool(); + await pool.query( + 'UPDATE organizations SET stripe_customer_id = NULL, updated_at = NOW() WHERE workos_organization_id = $1', + [workos_organization_id] + ); + } + + /** + * Find all Stripe customer ID conflicts between Stripe metadata and local DB. + * Returns cases where Stripe says customer belongs to org A but DB has it linked to org B. + */ + async findStripeCustomerConflicts(): Promise<Array<{ + stripe_customer_id: string; + stripe_says_org_id: string; + stripe_says_org_name: string | null; + db_has_org_id: string; + db_has_org_name: string; + }>> { + const conflicts: Array<{ + stripe_customer_id: string; + stripe_says_org_id: string; + stripe_says_org_name: string | null; + db_has_org_id: string; + db_has_org_name: string; + }> = []; + + // Get all Stripe customers with org metadata + const stripeCustomers = await listCustomersWithOrgIds(); + + for (const { stripeCustomerId, workosOrgId } of stripeCustomers) { + const dbOrg = await this.getOrganizationByStripeCustomerId(stripeCustomerId); + if (dbOrg && dbOrg.workos_organization_id !== workosOrgId) { + // Conflict: Stripe says org A, DB says org B + const stripeOrg = await this.getOrganization(workosOrgId); + conflicts.push({ + stripe_customer_id: stripeCustomerId, + stripe_says_org_id: workosOrgId, + stripe_says_org_name: stripeOrg?.name || null, + db_has_org_id: dbOrg.workos_organization_id, + db_has_org_name: dbOrg.name, + }); + } + } + + return conflicts; + } + /** * Get subscription info for an organization * Tries Stripe first if customer ID is available, falls back to local DB fields @@ -625,53 +717,61 @@ export class OrganizationDatabase { * This should be called during server startup after WorkOS sync. * Only updates orgs that exist locally but are missing stripe_customer_id. */ - async syncStripeCustomers(): Promise<{ synced: number; skipped: number }> { + async syncStripeCustomers(): Promise<{ synced: number; skipped: number; conflicts: number }> { let synced = 0; let skipped = 0; + let conflicts = 0; - try { - // Get all Stripe customers with WorkOS org IDs in metadata - const customers = await listCustomersWithOrgIds(); + // Get all Stripe customers with WorkOS org IDs in metadata + const customers = await listCustomersWithOrgIds(); - for (const { stripeCustomerId, workosOrgId } of customers) { - const localOrg = await this.getOrganization(workosOrgId); + for (const { stripeCustomerId, workosOrgId } of customers) { + const localOrg = await this.getOrganization(workosOrgId); - if (!localOrg) { - // Org doesn't exist locally - skip (WorkOS sync should have created it) - skipped++; - continue; - } + if (!localOrg) { + // Org doesn't exist locally - skip (WorkOS sync should have created it) + skipped++; + continue; + } - if (localOrg.stripe_customer_id === stripeCustomerId) { - // Already synced - continue; - } + if (localOrg.stripe_customer_id === stripeCustomerId) { + // Already synced + continue; + } - if (localOrg.stripe_customer_id && localOrg.stripe_customer_id !== stripeCustomerId) { - // Different customer ID - log warning but don't overwrite - logger.warn( - { orgId: workosOrgId, existingCustomerId: localOrg.stripe_customer_id, newCustomerId: stripeCustomerId }, - 'Organization has different Stripe customer ID - not overwriting' - ); - skipped++; - continue; - } + if (localOrg.stripe_customer_id && localOrg.stripe_customer_id !== stripeCustomerId) { + // Different customer ID - log warning but don't overwrite + logger.warn( + { orgId: workosOrgId, existingCustomerId: localOrg.stripe_customer_id, newCustomerId: stripeCustomerId }, + 'Organization has different Stripe customer ID - not overwriting' + ); + skipped++; + continue; + } - // Update the org with the Stripe customer ID + // Try to set the Stripe customer ID (setStripeCustomerId checks for conflicts) + try { await this.setStripeCustomerId(workosOrgId, stripeCustomerId); synced++; - logger.info({ orgId: workosOrgId, stripeCustomerId }, 'Synced Stripe customer ID to organization'); - } - - if (synced > 0) { - logger.info({ synced, skipped }, 'Stripe customer sync complete'); + logger.debug({ orgId: workosOrgId, stripeCustomerId }, 'Synced Stripe customer ID to organization'); + } catch (error) { + if (error instanceof StripeCustomerConflictError) { + logger.warn( + { stripeCustomerId, targetOrgId: workosOrgId, existingOrgId: error.existingOrgId, existingOrgName: error.existingOrgName }, + 'Stripe customer ID already assigned to different organization - skipping' + ); + conflicts++; + } else { + throw error; + } } + } - return { synced, skipped }; - } catch (error) { - logger.error({ error }, 'Failed to sync Stripe customers'); - throw error; + if (synced > 0 || conflicts > 0) { + logger.info({ synced, skipped, conflicts }, 'Stripe customer sync complete'); } + + return { synced, skipped, conflicts }; } // ======================================== diff --git a/server/src/http.ts b/server/src/http.ts index 6385a7f242..174685a79b 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -6823,12 +6823,9 @@ Disallow: /api/admin/ logger.warn({ error }, 'Failed to sync organizations from WorkOS (non-fatal)'); } - // Then sync Stripe customer IDs + // Then sync Stripe customer IDs (method handles errors gracefully) try { - const result = await orgDb.syncStripeCustomers(); - if (result.synced > 0) { - logger.info({ synced: result.synced, skipped: result.skipped }, 'Synced Stripe customer IDs'); - } + await orgDb.syncStripeCustomers(); } catch (error) { logger.warn({ error }, 'Failed to sync Stripe customers (non-fatal)'); } @@ -6845,11 +6842,11 @@ Disallow: /api/admin/ // Pre-warm caches for all agents in background const allAgents = await this.agentService.listAgents(); - logger.info({ agentCount: allAgents.length }, 'Pre-warming caches'); + logger.debug({ agentCount: allAgents.length }, 'Pre-warming caches'); // Don't await - let this run in background this.prewarmCaches(allAgents).then(() => { - logger.info('Cache pre-warming complete'); + logger.debug('Cache pre-warming complete'); }).catch(err => { logger.error({ err }, 'Cache pre-warming failed'); }); @@ -6857,7 +6854,7 @@ Disallow: /api/admin/ // Start periodic property crawler for sales agents const salesAgents = await this.agentService.listAgents("sales"); if (salesAgents.length > 0) { - logger.info({ salesAgentCount: salesAgents.length }, 'Starting property crawler'); + logger.debug({ salesAgentCount: salesAgents.length }, 'Starting property crawler'); this.crawler.startPeriodicCrawl(salesAgents, 60); // Crawl every 60 minutes } @@ -6962,7 +6959,7 @@ Disallow: /api/admin/ } }, CURATOR_INTERVAL_MINUTES * 60 * 1000); - logger.info({ intervalMinutes: CURATOR_INTERVAL_MINUTES }, 'Content curator started'); + logger.debug({ intervalMinutes: CURATOR_INTERVAL_MINUTES }, 'Content curator started'); } /** @@ -7023,7 +7020,7 @@ Disallow: /api/admin/ } }, ALERT_CHECK_INTERVAL_MINUTES * 60 * 1000); - logger.info({ + logger.debug({ feedFetchIntervalMinutes: FEED_FETCH_INTERVAL_MINUTES, alertCheckIntervalMinutes: ALERT_CHECK_INTERVAL_MINUTES, }, 'Industry monitor started'); @@ -7068,7 +7065,7 @@ Disallow: /api/admin/ } }, REMINDER_CHECK_INTERVAL_HOURS * 60 * 60 * 1000); - logger.info({ intervalHours: REMINDER_CHECK_INTERVAL_HOURS }, 'Task reminder job started'); + logger.debug({ intervalHours: REMINDER_CHECK_INTERVAL_HOURS }, 'Task reminder job started'); } /** @@ -7096,7 +7093,7 @@ Disallow: /api/admin/ } }, SCORING_INTERVAL_HOURS * 60 * 60 * 1000); - logger.info({ intervalHours: SCORING_INTERVAL_HOURS }, 'Engagement scoring job started'); + logger.debug({ intervalHours: SCORING_INTERVAL_HOURS }, 'Engagement scoring job started'); } /** @@ -7110,7 +7107,9 @@ Disallow: /api/admin/ setTimeout(async () => { try { const result = await runGoalFollowUpJob(); - logger.info(result, 'Goal follow-up: initial run completed'); + if (result.followUpsSent > 0 || result.goalsReconciled > 0) { + logger.info(result, 'Goal follow-up: initial run completed'); + } } catch (err) { logger.error({ err }, 'Goal follow-up: initial run failed'); } @@ -7146,7 +7145,7 @@ Disallow: /api/admin/ } }, FOLLOW_UP_INTERVAL_HOURS * 60 * 60 * 1000); - logger.info({ intervalHours: FOLLOW_UP_INTERVAL_HOURS }, 'Goal follow-up job started'); + logger.debug({ intervalHours: FOLLOW_UP_INTERVAL_HOURS }, 'Goal follow-up job started'); } /** diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts index 38a6d9e7ea..f05d28bf2f 100644 --- a/server/src/routes/billing.ts +++ b/server/src/routes/billing.ts @@ -25,6 +25,7 @@ import { type UpdateProductInput, type PendingInvoice, } from "../billing/stripe-client.js"; +import { OrganizationDatabase } from "../db/organization-db.js"; const logger = createLogger("billing-routes"); @@ -917,5 +918,196 @@ export function createBillingRouter(): { pageRouter: Router; apiRouter: Router } } }); + // ======================================== + // STRIPE CUSTOMER CONFLICT MANAGEMENT + // ======================================== + + /** + * GET /api/admin/stripe-conflicts + * List all Stripe customer ID conflicts between Stripe metadata and local DB + */ + apiRouter.get("/stripe-conflicts", requireAuth, requireAdmin, async (req, res) => { + try { + const orgDb = new OrganizationDatabase(); + const conflicts = await orgDb.findStripeCustomerConflicts(); + + res.json({ + conflicts, + count: conflicts.length, + message: conflicts.length > 0 + ? `Found ${conflicts.length} Stripe customer conflict(s). Review and resolve using POST /api/admin/stripe-conflicts/resolve` + : "No Stripe customer conflicts found", + }); + } catch (error) { + logger.error({ err: error }, "Error finding Stripe customer conflicts"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Failed to find conflicts", + }); + } + }); + + /** + * POST /api/admin/stripe-conflicts/resolve + * Resolve a Stripe customer conflict by choosing which org should own the customer + * Body: { stripe_customer_id, keep_org_id, action: "unlink_other" | "update_stripe_metadata" } + */ + apiRouter.post("/stripe-conflicts/resolve", requireAuth, requireAdmin, async (req, res) => { + try { + const { stripe_customer_id, keep_org_id, action } = req.body; + + if (!stripe_customer_id || !keep_org_id || !action) { + return res.status(400).json({ + error: "Missing required fields", + message: "Required: stripe_customer_id, keep_org_id, action (unlink_other or update_stripe_metadata)", + }); + } + + if (!["unlink_other", "update_stripe_metadata"].includes(action)) { + return res.status(400).json({ + error: "Invalid action", + message: "action must be 'unlink_other' or 'update_stripe_metadata'", + }); + } + + const orgDb = new OrganizationDatabase(); + + // Find the current state + const currentDbOrg = await orgDb.getOrganizationByStripeCustomerId(stripe_customer_id); + const keepOrg = await orgDb.getOrganization(keep_org_id); + + if (!keepOrg) { + return res.status(404).json({ + error: "Organization not found", + message: `Organization ${keep_org_id} not found`, + }); + } + + if (action === "unlink_other") { + // Unlink from current DB org, then link to keep_org + if (currentDbOrg && currentDbOrg.workos_organization_id !== keep_org_id) { + await orgDb.unlinkStripeCustomer(currentDbOrg.workos_organization_id); + logger.info( + { stripeCustomerId: stripe_customer_id, unlinkedFrom: currentDbOrg.workos_organization_id, adminEmail: req.user?.email }, + "Unlinked Stripe customer from organization during conflict resolution" + ); + } + + // Link to the keep_org (force in case of race conditions) + await orgDb.setStripeCustomerId(keep_org_id, stripe_customer_id, { force: true }); + + logger.info( + { stripeCustomerId: stripe_customer_id, linkedTo: keep_org_id, adminEmail: req.user?.email }, + "Resolved Stripe customer conflict - linked to chosen organization" + ); + + res.json({ + success: true, + message: `Stripe customer ${stripe_customer_id} is now linked to ${keepOrg.name} (${keep_org_id})`, + action_taken: "unlink_other", + previous_org: currentDbOrg ? { id: currentDbOrg.workos_organization_id, name: currentDbOrg.name } : null, + current_org: { id: keep_org_id, name: keepOrg.name }, + }); + + } else if (action === "update_stripe_metadata") { + // Update Stripe customer metadata to point to keep_org + if (!stripe) { + return res.status(500).json({ + error: "Stripe not initialized", + message: "Cannot update Stripe metadata - Stripe is not configured", + }); + } + + await stripe.customers.update(stripe_customer_id, { + metadata: { workos_organization_id: keep_org_id }, + }); + + // Also ensure DB is in sync + if (!currentDbOrg || currentDbOrg.workos_organization_id !== keep_org_id) { + if (currentDbOrg) { + await orgDb.unlinkStripeCustomer(currentDbOrg.workos_organization_id); + } + await orgDb.setStripeCustomerId(keep_org_id, stripe_customer_id, { force: true }); + } + + logger.info( + { stripeCustomerId: stripe_customer_id, linkedTo: keep_org_id, adminEmail: req.user?.email }, + "Resolved Stripe customer conflict - updated Stripe metadata and DB" + ); + + res.json({ + success: true, + message: `Updated Stripe customer ${stripe_customer_id} metadata and linked to ${keepOrg.name} (${keep_org_id})`, + action_taken: "update_stripe_metadata", + previous_org: currentDbOrg ? { id: currentDbOrg.workos_organization_id, name: currentDbOrg.name } : null, + current_org: { id: keep_org_id, name: keepOrg.name }, + }); + } + } catch (error) { + logger.error({ err: error }, "Error resolving Stripe customer conflict"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Failed to resolve conflict", + }); + } + }); + + /** + * POST /api/admin/stripe-customer/unlink + * Unlink a Stripe customer from an organization (without deleting the customer in Stripe) + * Body: { org_id } + */ + apiRouter.post("/stripe-customer/unlink", requireAuth, requireAdmin, async (req, res) => { + try { + const { org_id } = req.body; + + if (!org_id) { + return res.status(400).json({ + error: "Missing required field", + message: "Required: org_id", + }); + } + + const orgDb = new OrganizationDatabase(); + const org = await orgDb.getOrganization(org_id); + + if (!org) { + return res.status(404).json({ + error: "Organization not found", + message: `Organization ${org_id} not found`, + }); + } + + if (!org.stripe_customer_id) { + return res.status(400).json({ + error: "No Stripe customer linked", + message: `Organization ${org.name} has no Stripe customer linked`, + }); + } + + const previousCustomerId = org.stripe_customer_id; + await orgDb.unlinkStripeCustomer(org_id); + + logger.info( + { orgId: org_id, stripeCustomerId: previousCustomerId, adminEmail: req.user?.email }, + "Unlinked Stripe customer from organization" + ); + + res.json({ + success: true, + message: `Unlinked Stripe customer ${previousCustomerId} from ${org.name}`, + org_id, + org_name: org.name, + unlinked_customer_id: previousCustomerId, + }); + } catch (error) { + logger.error({ err: error }, "Error unlinking Stripe customer"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Failed to unlink customer", + }); + } + }); + return { pageRouter, apiRouter }; } From 7058988cc25fe28e9fb0a18c9b6c83187b7e73d1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 13:43:48 -0500 Subject: [PATCH 31/77] feat: add unified MCP server for external partner access (#706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose Addie's AI capabilities via MCP protocol at /mcp endpoint: - chat_with_addie tool for conversational access to AdCP knowledge - Directory tools for member/agent/publisher lookup - OAuth 2.1 authentication via WorkOS AuthKit - Rate limiting (5/min anonymous, 10/min authenticated) - Anonymous access restricted to safe tools (no Slack, no writes) - User attribution for bookmark_resource tool 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/clear-colts-wink.md | 2 + package-lock.json | 1 + package.json | 1 + server/src/addie/bolt-app.ts | 16 ++ server/src/addie/handler.ts | 19 ++ server/src/addie/mcp/directory-tools.ts | 287 +++++++++++++++++++ server/src/addie/mcp/knowledge-search.ts | 50 +++- server/src/addie/services/content-curator.ts | 3 + server/src/db/addie-db.ts | 5 +- server/src/http.ts | 83 +----- server/src/mcp/auth.ts | 280 ++++++++++++++++++ server/src/mcp/chat-tool.ts | 220 ++++++++++++++ server/src/mcp/index.ts | 23 ++ server/src/mcp/routes.ts | 163 +++++++++++ server/src/mcp/server.ts | 217 ++++++++++++++ 15 files changed, 1296 insertions(+), 74 deletions(-) create mode 100644 .changeset/clear-colts-wink.md create mode 100644 server/src/addie/mcp/directory-tools.ts create mode 100644 server/src/mcp/auth.ts create mode 100644 server/src/mcp/chat-tool.ts create mode 100644 server/src/mcp/index.ts create mode 100644 server/src/mcp/routes.ts create mode 100644 server/src/mcp/server.ts 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/package-lock.json b/package-lock.json index bf8fc70683..5458496d15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "express": "^4.22.1", "express-rate-limit": "^8.2.1", "free-email-domains": "^1.2.21", + "jose": "^6.1.3", "jsonwebtoken": "^9.0.3", "linkedom": "^0.18.12", "marked": "^17.0.1", diff --git a/package.json b/package.json index f9df837540..2d90a2aedb 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "express": "^4.22.1", "express-rate-limit": "^8.2.1", "free-email-domains": "^1.2.21", + "jose": "^6.1.3", "jsonwebtoken": "^9.0.3", "linkedom": "^0.18.12", "marked": "^17.0.1", diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index 7b01d1e230..c33d232ac7 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -35,6 +35,7 @@ import { isKnowledgeReady, KNOWLEDGE_TOOLS, createKnowledgeToolHandlers, + createUserScopedBookmarkHandler, } from './mcp/knowledge-search.js'; import { MEMBER_TOOLS, @@ -73,6 +74,7 @@ import { getGoalsForSystemPrompt } from './services/insight-extractor.js'; import { getHomeContent, renderHomeView, renderErrorView, invalidateHomeCache } from './home/index.js'; import { URL_TOOLS, createUrlToolHandlers } from './mcp/url-tools.js'; import { GOOGLE_DOCS_TOOLS, createGoogleDocsToolHandlers } from './mcp/google-docs.js'; +import { DIRECTORY_TOOLS, createDirectoryToolHandlers } from './mcp/directory-tools.js'; import { initializeEmailHandler } from './email-handler.js'; import { isManagedChannel, @@ -356,6 +358,15 @@ export async function initializeAddieBolt(): Promise<{ app: InstanceType<typeof logger.info('Addie: Google Docs tools registered'); } + // Register directory tools (member/agent/publisher lookup) + const directoryHandlers = createDirectoryToolHandlers(); + for (const tool of DIRECTORY_TOOLS) { + const handler = directoryHandlers.get(tool.name); + if (handler) { + claudeClient.registerTool(tool, handler); + } + } + // Create the Assistant const assistant = new Assistant({ threadContextStore, @@ -635,6 +646,11 @@ async function createUserScopedTools( logger.debug('Addie Bolt: Event tools enabled for this user'); } + // Override bookmark_resource handler with user-scoped version (for attribution) + if (slackUserId) { + allHandlers.set('bookmark_resource', createUserScopedBookmarkHandler(slackUserId)); + } + return { tools: { tools: allTools, diff --git a/server/src/addie/handler.ts b/server/src/addie/handler.ts index ea5f7b1739..674c465520 100644 --- a/server/src/addie/handler.ts +++ b/server/src/addie/handler.ts @@ -21,6 +21,7 @@ import { isKnowledgeReady, KNOWLEDGE_TOOLS, createKnowledgeToolHandlers, + createUserScopedBookmarkHandler, } from './mcp/knowledge-search.js'; import { BILLING_TOOLS, @@ -40,6 +41,10 @@ import { createEventToolHandlers, canCreateEvents, } from './mcp/event-tools.js'; +import { + DIRECTORY_TOOLS, + createDirectoryToolHandlers, +} from './mcp/directory-tools.js'; import { AddieDatabase } from '../db/addie-db.js'; import { SUGGESTED_PROMPTS, STATUS_MESSAGES, buildDynamicSuggestedPrompts } from './prompts.js'; import { AddieModelConfig } from '../config/models.js'; @@ -136,6 +141,15 @@ export async function initializeAddie(): Promise<void> { } } + // Register directory tools (lookup members, agents, publishers) + const directoryHandlers = createDirectoryToolHandlers(); + for (const tool of DIRECTORY_TOOLS) { + const handler = directoryHandlers.get(tool.name); + if (handler) { + claudeClient.registerTool(tool, handler); + } + } + initialized = true; logger.info({ tools: claudeClient.getRegisteredTools() }, 'Addie: Ready'); } @@ -253,6 +267,11 @@ async function createUserScopedTools( logger.debug('Addie: Event tools enabled for this user'); } + // Override bookmark_resource handler with user-scoped version (for attribution) + if (slackUserId) { + allHandlers.set('bookmark_resource', createUserScopedBookmarkHandler(slackUserId)); + } + return { tools: { tools: allTools, diff --git a/server/src/addie/mcp/directory-tools.ts b/server/src/addie/mcp/directory-tools.ts new file mode 100644 index 0000000000..2b61beea76 --- /dev/null +++ b/server/src/addie/mcp/directory-tools.ts @@ -0,0 +1,287 @@ +/** + * Directory Tools for Addie + * + * Provides access to the AAO member directory, agent registry, and publisher index. + * These are the same capabilities as the MCP Directory server, but formatted for Addie. + */ + +import type { AddieTool } from '../types.js'; +import { MemberDatabase } from '../../db/member-db.js'; +import { AgentService } from '../../agent-service.js'; +import { AgentValidator } from '../../validator.js'; +import { FederatedIndexService } from '../../federated-index.js'; +import type { AgentType, MemberOffering, Agent } from '../../types.js'; + +const memberDb = new MemberDatabase(); +const agentService = new AgentService(); +const validator = new AgentValidator(); +const federatedIndex = new FederatedIndexService(); + +/** + * Directory tool definitions for Addie + */ +export const DIRECTORY_TOOLS: AddieTool[] = [ + { + name: 'list_members', + description: 'List AgenticAdvertising.org member organizations. Can filter by offerings (buyer_agent, sales_agent, creative_agent, signals_agent, publisher, consulting), markets (North America, EMEA, APAC, LATAM, Global), or search term.', + usage_hints: 'Use when asked about AAO members, member organizations, who is in the directory, or companies that offer specific services.', + input_schema: { + type: 'object', + properties: { + offerings: { + type: 'array', + items: { + type: 'string', + enum: ['buyer_agent', 'sales_agent', 'creative_agent', 'signals_agent', 'publisher', 'consulting', 'other'], + }, + description: 'Filter by member offerings', + }, + markets: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by markets served', + }, + search: { + type: 'string', + description: 'Search term to filter by name, description, or tags', + }, + limit: { + type: 'number', + description: 'Maximum results (default: 20)', + }, + }, + }, + }, + { + name: 'get_member', + description: 'Get detailed information about a specific AAO member by their slug identifier.', + usage_hints: 'Use when asked for details about a specific member organization.', + input_schema: { + type: 'object', + properties: { + slug: { + type: 'string', + description: 'Member slug (e.g., "pubmatic", "yahoo")', + }, + }, + required: ['slug'], + }, + }, + { + name: 'list_agents', + description: 'List all public AdCP agents from member organizations. Can filter by type: creative (asset generation), signals (audience data), or sales (media buying).', + usage_hints: 'Use when asked about registered agents, what agents are available, or agents of a specific type.', + input_schema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['creative', 'signals', 'sales'], + description: 'Filter by agent type', + }, + }, + }, + }, + { + name: 'get_agent', + description: 'Get details for a specific agent by its URL.', + usage_hints: 'Use when asked about a specific agent.', + input_schema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Agent URL (e.g., "https://sales.example.com")', + }, + }, + required: ['url'], + }, + }, + { + name: 'validate_agent', + description: 'Validate if an agent is authorized for a publisher domain by checking their /.well-known/adagents.json file.', + usage_hints: 'Use when asked if an agent can sell for a publisher, or to verify agent authorization.', + input_schema: { + type: 'object', + properties: { + domain: { + type: 'string', + description: 'Publisher domain (e.g., "nytimes.com")', + }, + agent_url: { + type: 'string', + description: 'Agent URL to validate', + }, + }, + required: ['domain', 'agent_url'], + }, + }, + { + name: 'lookup_domain', + description: 'Find all agents authorized for a specific publisher domain. Shows both verified agents (from adagents.json) and claimed agents (from agent registrations).', + usage_hints: 'Use when asked which agents can sell inventory for a domain, or who represents a publisher.', + input_schema: { + type: 'object', + properties: { + domain: { + type: 'string', + description: 'Publisher domain (e.g., "nytimes.com")', + }, + }, + required: ['domain'], + }, + }, + { + name: 'list_publishers', + description: 'List all publishers that have published a /.well-known/adagents.json file, indicating they support AdCP.', + usage_hints: 'Use when asked which publishers support AdCP, or who has set up adagents.json.', + input_schema: { + type: 'object', + properties: {}, + }, + }, +]; + +/** + * Create handlers for directory tools + */ +export function createDirectoryToolHandlers(): Map<string, (args: Record<string, unknown>) => Promise<string>> { + const handlers = new Map<string, (args: Record<string, unknown>) => Promise<string>>(); + + handlers.set('list_members', async (args) => { + const offerings = args.offerings as MemberOffering[] | undefined; + const markets = args.markets as string[] | undefined; + const search = args.search as string | undefined; + // Validate limit: default 20, max 100 + const rawLimit = typeof args.limit === 'number' ? args.limit : 20; + const limit = Math.min(Math.max(1, rawLimit), 100); + + const members = await memberDb.getPublicProfiles({ + offerings, + markets, + search, + limit, + }); + + if (members.length === 0) { + return 'No members found matching the criteria.'; + } + + const result = members.map((m) => ({ + name: m.display_name, + slug: m.slug, + tagline: m.tagline, + offerings: m.offerings, + headquarters: m.headquarters, + markets: m.markets, + website: m.contact_website, + agents: m.agents.filter((a) => a.is_public).map((a) => ({ + name: a.name, + type: a.type, + url: a.url, + })), + })); + + return JSON.stringify({ members: result, count: result.length }, null, 2); + }); + + handlers.set('get_member', async (args) => { + const slug = args.slug as string; + if (!slug) { + return JSON.stringify({ error: 'slug is required' }); + } + + const member = await memberDb.getProfileBySlug(slug); + if (!member || !member.is_public) { + return JSON.stringify({ error: `Member "${slug}" not found or not public` }); + } + + return JSON.stringify({ + name: member.display_name, + slug: member.slug, + tagline: member.tagline, + description: member.description, + offerings: member.offerings, + headquarters: member.headquarters, + markets: member.markets, + website: member.contact_website, + logo: member.logo_url, + agents: member.agents.filter((a) => a.is_public).map((a) => ({ + name: a.name, + type: a.type, + url: a.url, + })), + }, null, 2); + }); + + handlers.set('list_agents', async (args) => { + const agentType = args.type as AgentType | undefined; + const agents = await agentService.listAgents(agentType); + + if (agents.length === 0) { + return agentType + ? `No ${agentType} agents found.` + : 'No agents found.'; + } + + const result = agents.map((a: Agent) => ({ + name: a.name, + type: a.type, + url: a.url, + description: a.description, + contact: a.contact, + })); + + return JSON.stringify({ agents: result, count: result.length }, null, 2); + }); + + handlers.set('get_agent', async (args) => { + const url = args.url as string; + if (!url) { + return JSON.stringify({ error: 'url is required' }); + } + + const agent = await agentService.getAgentByUrl(url); + if (!agent) { + return JSON.stringify({ error: `Agent "${url}" not found` }); + } + + return JSON.stringify({ + name: agent.name, + type: agent.type, + url: agent.url, + description: agent.description, + contact: agent.contact, + mcp_endpoint: agent.mcp_endpoint, + }, null, 2); + }); + + handlers.set('validate_agent', async (args) => { + const domain = args.domain as string; + const agentUrl = args.agent_url as string; + + if (!domain || !agentUrl) { + return JSON.stringify({ error: 'domain and agent_url are required' }); + } + + const result = await validator.validate(domain, agentUrl); + return JSON.stringify(result, null, 2); + }); + + handlers.set('lookup_domain', async (args) => { + const domain = args.domain as string; + if (!domain) { + return JSON.stringify({ error: 'domain is required' }); + } + + const result = await federatedIndex.lookupDomain(domain); + return JSON.stringify(result, null, 2); + }); + + handlers.set('list_publishers', async () => { + const publishers = await federatedIndex.listAllPublishers(); + return JSON.stringify({ publishers, count: publishers.length }, null, 2); + }); + + return handlers; +} diff --git a/server/src/addie/mcp/knowledge-search.ts b/server/src/addie/mcp/knowledge-search.ts index 9e4b30bba1..d0477c97f3 100644 --- a/server/src/addie/mcp/knowledge-search.ts +++ b/server/src/addie/mcp/knowledge-search.ts @@ -800,11 +800,12 @@ ${resource.addie_notes ? `**Addie's Take:** ${resource.addie_notes}` : ''}`; return `This resource is already in the knowledge base: ${url}`; } - // Queue for indexing + // Queue for indexing (no user context in default handler) const id = await queueWebSearchResult({ url, title, searchQuery: reason, // Use reason as context + created_by: 'system', // Default when no user context available }); if (id === 0) { @@ -875,3 +876,50 @@ ${article.addie_notes ? `**Addie's Take:** ${article.addie_notes}` : ''}`; return handlers; } + +/** + * Create a user-scoped bookmark_resource handler + * Used by Slack handlers to attribute bookmarks to the user who created them + */ +export function createUserScopedBookmarkHandler( + slackUserId: string +): (input: Record<string, unknown>) => Promise<string> { + return async (input) => { + const url = input.url as string; + const title = input.title as string; + const reason = input.reason as string; + + // Validate URL + try { + new URL(url); + } catch { + return `Invalid URL: "${url}". Please provide a valid URL.`; + } + + try { + // Check if already indexed + const isIndexed = await addieDb.isUrlIndexed(url); + if (isIndexed) { + return `This resource is already in the knowledge base: ${url}`; + } + + // Queue for indexing with user attribution + const id = await queueWebSearchResult({ + url, + title, + searchQuery: reason, + created_by: slackUserId, + }); + + if (id === 0) { + return `Resource was already queued or could not be added: ${url}`; + } + + logger.info({ url, title, reason, slackUserId }, 'Addie bookmarked resource (user-scoped)'); + return `Bookmarked "${title}" for indexing. The content will be fetched, summarized, and added to the knowledge base shortly. You can search for it later using search_resources.`; + } catch (error) { + logger.error({ error, url, slackUserId }, 'Addie: User-scoped bookmark failed'); + return `Failed to bookmark resource: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }; +} diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts index 2f8854c0c2..8e99ae3332 100644 --- a/server/src/addie/services/content-curator.ts +++ b/server/src/addie/services/content-curator.ts @@ -393,6 +393,8 @@ export async function queueWebSearchResult(result: { url: string; title: string; searchQuery: string; + /** Who bookmarked this - Slack user ID, WorkOS user ID, or 'system' */ + created_by?: string; }): Promise<number> { // Check if already indexed const isIndexed = await addieDb.isUrlIndexed(result.url); @@ -409,6 +411,7 @@ export async function queueWebSearchResult(result: { discovery_context: { search_query: result.searchQuery, }, + created_by: result.created_by, }); } diff --git a/server/src/db/addie-db.ts b/server/src/db/addie-db.ts index 4fa5bf481f..16d9c0ea86 100644 --- a/server/src/db/addie-db.ts +++ b/server/src/db/addie-db.ts @@ -110,6 +110,8 @@ export interface CuratedResourceInput { discovery_source: 'perspective_publish' | 'web_search' | 'slack_link' | 'manual'; discovery_context?: Record<string, unknown>; relevance_tags?: string[]; + /** Who created this resource - Slack user ID, WorkOS user ID, or 'system' */ + created_by?: string; } export interface CuratedResourceSearchResult { @@ -715,7 +717,7 @@ export class AddieDatabase { title, category, content, source_url, fetch_url, source_type, fetch_status, discovery_source, discovery_context, relevance_tags, created_by - ) VALUES ($1, $2, '', $3, $3, $4, 'pending', $5, $6, $7, 'system') + ) VALUES ($1, $2, '', $3, $3, $4, 'pending', $5, $6, $7, $8) ON CONFLICT DO NOTHING RETURNING id`, [ @@ -726,6 +728,7 @@ export class AddieDatabase { input.discovery_source, input.discovery_context ? JSON.stringify(input.discovery_context) : null, input.relevance_tags || null, + input.created_by || 'system', ] ); return result.rows[0]?.id ?? 0; diff --git a/server/src/http.ts b/server/src/http.ts index 174685a79b..7ea8906734 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -4,10 +4,9 @@ import * as fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; import { WorkOS, DomainDataState } from "@workos-inc/node"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { AgentService } from "./agent-service.js"; import { AgentValidator } from "./validator.js"; -import { createMCPServer } from "./mcp-tools.js"; +import { configureMCPRoutes, initializeMCPServer, isMCPServerReady } from "./mcp/index.js"; import { HealthChecker } from "./health.js"; import { CrawlerService } from "./crawler.js"; import { createLogger } from "./logger.js"; @@ -1475,76 +1474,10 @@ export class HTTPServer { }); }); - // MCP endpoint - for AI agents to discover other agents - // Uses StreamableHTTPServerTransport from the MCP SDK for stateless HTTP transport - - // CORS preflight for MCP endpoint - this.app.options("/mcp", (req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); - res.status(204).end(); - }); - - // MCP POST handler - stateless mode (new server/transport per request) - this.app.post("/mcp", async (req, res) => { - // Add CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); - - try { - // Create a new MCP server and transport for each request (stateless mode) - const server = createMCPServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // Stateless mode - no sessions - }); - - // Connect server to transport - await server.connect(transport); - - // Handle the request - await transport.handleRequest(req, res, req.body); - - // Clean up after response is sent - res.on('close', () => { - transport.close(); - server.close(); - }); - } catch (error: any) { - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { - code: -32603, - message: error?.message || "Internal error", - }, - }); - } - } - }); - - // MCP GET handler - not supported in stateless mode - this.app.get("/mcp", (req, res) => { - res.status(405).json({ - jsonrpc: "2.0", - error: { - code: -32601, - message: "Method not allowed. Use POST for MCP requests.", - }, - }); - }); - - // MCP DELETE handler - not needed in stateless mode - this.app.delete("/mcp", (req, res) => { - res.status(405).json({ - jsonrpc: "2.0", - error: { - code: -32601, - message: "Method not allowed. Session management not supported in stateless mode.", - }, - }); - }); + // MCP endpoint - unified server with all Addie capabilities + // Supports OAuth 2.1 (users adding to Claude/ChatGPT) and M2M (partner bots) + // Auth via WorkOS AuthKit + configureMCPRoutes(this.app); // Health check - verifies critical services are operational this.app.get("/health", async (req, res) => { @@ -1567,6 +1500,12 @@ export class HTTPServer { allHealthy = false; } + // Check MCP server status + checks.mcp = isMCPServerReady(); + if (!checks.mcp) { + allHealthy = false; + } + // Return appropriate status code const statusCode = allHealthy ? 200 : 503; res.status(statusCode).json({ diff --git a/server/src/mcp/auth.ts b/server/src/mcp/auth.ts new file mode 100644 index 0000000000..0b9a8c820a --- /dev/null +++ b/server/src/mcp/auth.ts @@ -0,0 +1,280 @@ +/** + * MCP Authentication Middleware + * + * Supports two access modes: + * + * 1. Authenticated (OAuth 2.1) + * - User adds Addie to Claude Desktop or ChatGPT + * - Redirected to WorkOS login, gets access token + * - Full tools available based on membership status + * + * 2. Anonymous + * - No authentication required + * - Knowledge tools only (search_docs, search_repos, etc.) + * - Rate limited by IP address + * + * JWT validation via WorkOS JWKS: + * - Token signature verification + * - Issuer matches AuthKit + * - Expiry (exp) and not-before (nbf) claims + * - Audience matches resource identifier + */ + +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; +import type { Request, Response, NextFunction } from 'express'; +import { createLogger } from '../logger.js'; + +const logger = createLogger('mcp-auth'); + +/** + * WorkOS AuthKit configuration + * + * AUTHKIT_ISSUER: The AuthKit issuer URL + * Defaults to AgenticAdvertising.org's AuthKit domain + * + * MCP_RESOURCE_ID: The resource identifier for this MCP server + * Used as the expected audience in JWT validation + * Defaults to production MCP endpoint + */ +const AUTHKIT_ISSUER = process.env.AUTHKIT_ISSUER || 'https://clean-gradient-46.authkit.app'; +const MCP_RESOURCE_ID = process.env.MCP_RESOURCE_ID || 'https://agenticadvertising.org/mcp'; + +/** + * Whether MCP auth is enabled + * Can be disabled via MCP_AUTH_DISABLED=true for local development + */ +export const MCP_AUTH_ENABLED = process.env.MCP_AUTH_DISABLED !== 'true'; + +/** + * JWKS (JSON Web Key Set) for token verification + * Cached and refreshed automatically by jose library + */ +let jwks: ReturnType<typeof createRemoteJWKSet> | null = null; + +function getJWKS() { + if (!jwks && MCP_AUTH_ENABLED) { + const jwksUrl = new URL(`${AUTHKIT_ISSUER}/oauth2/jwks`); + jwks = createRemoteJWKSet(jwksUrl); + logger.info({ jwksUrl: jwksUrl.toString() }, 'MCP Auth: JWKS configured'); + } + return jwks; +} + +/** + * Claims extracted from validated JWT + */ +export interface MCPAuthContext { + /** Subject - user ID (OAuth) or client ID (M2M) */ + sub: string; + /** Organization ID - for scoping access and rate limits */ + orgId?: string; + /** Whether this is an M2M (machine) token vs user token */ + isM2M: boolean; + /** User email (if OAuth flow with email scope) */ + email?: string; + /** Raw JWT payload for additional claims */ + payload: JWTPayload; +} + +/** + * Express request with MCP auth context + */ +export interface MCPAuthenticatedRequest extends Request { + mcpAuth?: MCPAuthContext; +} + +/** + * Extract bearer token from Authorization header + */ +function extractBearerToken(req: Request): string | null { + const authHeader = req.headers.authorization; + if (!authHeader) return null; + + const match = authHeader.match(/^Bearer\s+(.+)$/i); + return match ? match[1] : null; +} + +/** + * Validate JWT and extract claims + * + * Per MCP auth spec: + * - Verify signature via JWKS + * - Check issuer matches AuthKit + * - Validate exp/nbf claims (handled by jose library) + * - Optionally check audience matches resource identifier + */ +async function validateToken(token: string): Promise<MCPAuthContext> { + const jwksInstance = getJWKS(); + if (!jwksInstance) { + throw new Error('JWKS not configured'); + } + + // Build verification options + const verifyOptions: { issuer: string; audience?: string } = { + issuer: AUTHKIT_ISSUER, + }; + + // Only validate audience if MCP_RESOURCE_ID is configured + if (MCP_RESOURCE_ID) { + verifyOptions.audience = MCP_RESOURCE_ID; + } + + const { payload } = await jwtVerify(token, jwksInstance, verifyOptions); + + // Determine if this is M2M based on grant type or subject format + // M2M tokens typically have grant_type: client_credentials + // or the subject starts with 'client_' + const isM2M = payload.grant_type === 'client_credentials' || + (typeof payload.sub === 'string' && payload.sub.startsWith('client_')); + + return { + sub: payload.sub as string, + orgId: payload.org_id as string | undefined, + isM2M, + email: payload.email as string | undefined, + payload, + }; +} + +/** + * MCP authentication middleware + * + * Validates bearer token and attaches auth context to request. + * Returns 401 if token is missing or invalid. + * + * Usage: + * ```typescript + * app.post('/mcp', mcpAuthMiddleware, handleMCPRequest); + * ``` + */ +export async function mcpAuthMiddleware( + req: MCPAuthenticatedRequest, + res: Response, + next: NextFunction +): Promise<void> { + // Skip auth if not enabled (development mode) + if (!MCP_AUTH_ENABLED) { + logger.debug('MCP Auth: Disabled, skipping authentication'); + req.mcpAuth = { + sub: 'anonymous', + isM2M: false, + payload: {}, + }; + next(); + return; + } + + const token = extractBearerToken(req); + + if (!token) { + logger.debug('MCP Auth: No bearer token provided'); + + // Return WWW-Authenticate header per MCP spec + res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${getResourceMetadataUrl(req)}"`); + res.status(401).json({ + jsonrpc: '2.0', + error: { + code: -32001, + message: 'Authentication required. Provide a bearer token.', + }, + }); + return; + } + + try { + const authContext = await validateToken(token); + req.mcpAuth = authContext; + + logger.debug({ + sub: authContext.sub, + orgId: authContext.orgId, + isM2M: authContext.isM2M, + }, 'MCP Auth: Token validated'); + + next(); + } catch (error) { + logger.warn({ error }, 'MCP Auth: Token validation failed'); + + res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${getResourceMetadataUrl(req)}", error="invalid_token"`); + res.status(401).json({ + jsonrpc: '2.0', + error: { + code: -32001, + message: 'Invalid or expired token.', + }, + }); + } +} + +/** + * Optional MCP auth middleware + * + * Like mcpAuthMiddleware but allows unauthenticated requests. + * Use for endpoints that have both public and authenticated features. + */ +export async function optionalMcpAuthMiddleware( + req: MCPAuthenticatedRequest, + res: Response, + next: NextFunction +): Promise<void> { + if (!MCP_AUTH_ENABLED) { + next(); + return; + } + + const token = extractBearerToken(req); + + if (!token) { + // No token, continue without auth context + next(); + return; + } + + try { + const authContext = await validateToken(token); + req.mcpAuth = authContext; + next(); + } catch (error) { + // Invalid token - still continue but without auth + logger.debug({ error }, 'MCP Auth: Optional token validation failed'); + next(); + } +} + +/** + * Get the resource metadata URL for this server + */ +function getResourceMetadataUrl(req: Request): string { + const protocol = req.secure || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; + const host = req.headers.host || 'localhost'; + return `${protocol}://${host}/.well-known/oauth-protected-resource`; +} + +/** + * OAuth Protected Resource Metadata + * + * Per RFC 8707 and MCP spec, this endpoint tells clients where to authenticate. + * MCP clients use this to discover the authorization server. + */ +export function getOAuthProtectedResourceMetadata(req: Request) { + const protocol = req.secure || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; + const host = req.headers.host || 'localhost'; + const baseUrl = `${protocol}://${host}`; + + return { + resource: `${baseUrl}/mcp`, + authorization_servers: [AUTHKIT_ISSUER], + bearer_methods_supported: ['header'], + scopes_supported: ['openid', 'profile', 'email'], + }; +} + +/** + * OAuth Authorization Server Metadata URL + * + * Returns the URL where clients can find OAuth server metadata. + * WorkOS AuthKit provides this at /.well-known/openid-configuration + */ +export function getAuthorizationServerMetadataUrl(): string { + return `${AUTHKIT_ISSUER}/.well-known/openid-configuration`; +} diff --git a/server/src/mcp/chat-tool.ts b/server/src/mcp/chat-tool.ts new file mode 100644 index 0000000000..0d1dd1b558 --- /dev/null +++ b/server/src/mcp/chat-tool.ts @@ -0,0 +1,220 @@ +/** + * Chat with Addie MCP Tool + * + * Exposes Addie's conversational AI as a single MCP tool. + * This allows external partners to embed Addie in their applications + * without needing to call individual knowledge tools. + * + * For anonymous/partner access (safe tools only): + * - search_docs, get_doc: Public documentation + * - search_repos: Public GitHub repos (OpenRTB, MCP, A2A specs, etc.) + * - search_resources, get_recent_news: Curated public content + * - Directory tools: Public member/agent/publisher info + * + * NOT available to anonymous (require Slack membership): + * - search_slack, get_channel_activity: Internal community discussions + * - bookmark_resource: Writes to database + */ + +import { AddieClaudeClient, type RequestTools } from '../addie/claude-client.js'; +import { AddieModelConfig } from '../config/models.js'; +import { + KNOWLEDGE_TOOLS, + createKnowledgeToolHandlers, + isKnowledgeReady, +} from '../addie/mcp/knowledge-search.js'; +import { + DIRECTORY_TOOLS, + createDirectoryToolHandlers, +} from '../addie/mcp/directory-tools.js'; +import { createLogger } from '../logger.js'; +import type { AddieTool } from '../addie/types.js'; + +const logger = createLogger('mcp-chat'); + +/** + * Knowledge tools safe for anonymous users (no Slack access, no writes) + * Directory tools are also available to anonymous users (public data) + * but are registered separately below. + */ +const ANONYMOUS_SAFE_KNOWLEDGE_TOOLS = new Set([ + 'search_docs', + 'get_doc', + 'search_repos', + 'search_resources', + 'get_recent_news', +]); + +/** + * Conversation message for history + */ +interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; +} + +/** + * Chat tool input schema + */ +interface ChatInput { + message: string; + history?: ConversationMessage[]; +} + +/** + * Chat tool response + */ +interface ChatResponse { + response: string; + tools_used: string[]; +} + +/** + * Lazy-initialized Claude client for chat tool + * Uses a separate instance from the main Addie handler to avoid state conflicts + */ +let chatClient: AddieClaudeClient | null = null; + +function getChatClient(): AddieClaudeClient { + if (!chatClient) { + const apiKey = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('Anthropic API key not configured'); + } + + chatClient = new AddieClaudeClient(apiKey, AddieModelConfig.chat); + + // Register only safe knowledge tools (no Slack, no writes) + const knowledgeHandlers = createKnowledgeToolHandlers(); + for (const tool of KNOWLEDGE_TOOLS) { + if (ANONYMOUS_SAFE_KNOWLEDGE_TOOLS.has(tool.name)) { + const handler = knowledgeHandlers.get(tool.name); + if (handler) { + chatClient.registerTool(tool, handler); + } + } + } + + // Register directory tools (public member/agent/publisher lookup - safe for anonymous) + const directoryHandlers = createDirectoryToolHandlers(); + for (const tool of DIRECTORY_TOOLS) { + const handler = directoryHandlers.get(tool.name); + if (handler) { + chatClient.registerTool(tool, handler); + } + } + + logger.info({ tools: chatClient.getRegisteredTools() }, 'MCP Chat: Client initialized'); + } + + return chatClient; +} + +/** + * The chat_with_addie tool definition + */ +export const CHAT_TOOL: AddieTool = { + name: 'chat_with_addie', + description: `Chat with Addie, the AgenticAdvertising.org community assistant. Addie is an expert on: +- AdCP (Advertising Context Protocol) - the open standard for AI-powered advertising +- MCP (Model Context Protocol) and A2A (Agent-to-Agent) protocols +- IAB Tech Lab specifications (OpenRTB, VAST, etc.) +- Ad tech industry trends and best practices +- AgenticAdvertising.org member directory, registered agents, and publishers + +Addie can search documentation, code repositories, the member directory, and curated industry resources to answer questions. + +Use this for conversational questions about ad tech, protocols, member organizations, and the advertising industry. +Include 'history' for multi-turn conversations.`, + usage_hints: 'Use when the user wants to have a conversation about ad tech topics or needs help understanding protocols.', + input_schema: { + type: 'object', + properties: { + message: { + type: 'string', + description: 'The message to send to Addie', + }, + history: { + type: 'array', + description: 'Optional conversation history for context (most recent last)', + items: { + type: 'object', + properties: { + role: { + type: 'string', + enum: ['user', 'assistant'], + description: 'Who sent the message', + }, + content: { + type: 'string', + description: 'The message content', + }, + }, + required: ['role', 'content'], + }, + }, + }, + required: ['message'], + }, +}; + +/** + * Handle the chat_with_addie tool call + */ +export async function handleChatTool(args: Record<string, unknown>): Promise<string> { + const message = args.message; + const history = args.history as ConversationMessage[] | undefined; + + if (!message || typeof message !== 'string') { + return JSON.stringify({ + error: 'message is required and must be a string', + }); + } + + // Check if knowledge search is ready + if (!isKnowledgeReady()) { + return JSON.stringify({ + error: 'Addie is still initializing. Please try again in a moment.', + }); + } + + try { + const client = getChatClient(); + + // Convert history to thread context format expected by AddieClaudeClient + const threadContext = history?.map((msg) => ({ + user: msg.role === 'user' ? 'user' : 'assistant', + text: msg.content, + })); + + // Process the message + const response = await client.processMessage( + message, + threadContext, + undefined, // No request-specific tools for anonymous + undefined, // No rules override + { maxIterations: 5 } // Lower iteration limit for anonymous users + ); + + const result: ChatResponse = { + response: response.text, + tools_used: response.tools_used, + }; + + return JSON.stringify(result); + } catch (error) { + logger.error({ error }, 'MCP Chat: Error processing message'); + return JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to process message', + }); + } +} + +/** + * Create the chat tool handler map + */ +export function createChatToolHandler(): Map<string, (args: Record<string, unknown>) => Promise<string>> { + const handlers = new Map<string, (args: Record<string, unknown>) => Promise<string>>(); + handlers.set(CHAT_TOOL.name, handleChatTool); + return handlers; +} diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts new file mode 100644 index 0000000000..9ff5796d4e --- /dev/null +++ b/server/src/mcp/index.ts @@ -0,0 +1,23 @@ +/** + * MCP Module + * + * Unified MCP server combining all Addie capabilities: + * - Knowledge search (docs, repos, Slack, external resources) + * - Directory lookup (members, agents, publishers) + * - Billing operations (membership products, payment links) + * + * Access modes: + * - Authenticated (OAuth 2.1): Full tools based on membership (10/min) + * - Anonymous: Knowledge tools only, rate limited by IP (5/min) + */ + +export { createUnifiedMCPServer, initializeMCPServer, isMCPServerReady, getAllTools } from './server.js'; +export { configureMCPRoutes } from './routes.js'; +export { + optionalMcpAuthMiddleware, + getOAuthProtectedResourceMetadata, + getAuthorizationServerMetadataUrl, + MCP_AUTH_ENABLED, + type MCPAuthContext, + type MCPAuthenticatedRequest, +} from './auth.js'; diff --git a/server/src/mcp/routes.ts b/server/src/mcp/routes.ts new file mode 100644 index 0000000000..27302838af --- /dev/null +++ b/server/src/mcp/routes.ts @@ -0,0 +1,163 @@ +/** + * MCP Route Handlers + * + * Configures Express routes for the unified MCP server: + * - POST /mcp - MCP JSON-RPC endpoint (optional auth, rate limited) + * - GET /.well-known/oauth-protected-resource - OAuth resource metadata + * - OPTIONS /mcp - CORS preflight + * + * Access modes: + * - Authenticated: Full tools based on membership + * - Anonymous: Knowledge tools only, rate limited by IP + */ + +import type { Router, Request, Response } from 'express'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import rateLimit from 'express-rate-limit'; +import { ipKeyGenerator } from 'express-rate-limit'; +import { createLogger } from '../logger.js'; +import { createUnifiedMCPServer } from './server.js'; +import { + optionalMcpAuthMiddleware, + getOAuthProtectedResourceMetadata, + MCP_AUTH_ENABLED, + type MCPAuthenticatedRequest, +} from './auth.js'; + +const logger = createLogger('mcp-routes'); + +/** + * Rate limiter for MCP endpoint + * Anonymous: 5 requests per minute per IP + * Authenticated: 10 requests per minute per user + */ +const mcpRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: (req: MCPAuthenticatedRequest) => { + // Authenticated users get slightly higher limits + if (req.mcpAuth?.sub && req.mcpAuth.sub !== 'anonymous') { + return 10; // 10 requests per minute for authenticated users + } + return 5; // 5 requests per minute for anonymous + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: MCPAuthenticatedRequest) => { + // Use user ID for authenticated, IP for anonymous + if (req.mcpAuth?.sub && req.mcpAuth.sub !== 'anonymous') { + return `user:${req.mcpAuth.sub}`; + } + // Use ipKeyGenerator helper to handle IPv6 properly + return ipKeyGenerator(req.ip || 'unknown'); + }, + handler: (req, res) => { + res.status(429).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Rate limit exceeded. Try again later.', + }, + }); + }, +}); + +/** + * Configure MCP routes on an Express router + */ +export function configureMCPRoutes(router: Router): void { + // OAuth Protected Resource Metadata + // MCP clients use this to discover where to authenticate + router.get('/.well-known/oauth-protected-resource', (req: Request, res: Response) => { + const metadata = getOAuthProtectedResourceMetadata(req); + logger.debug({ metadata }, 'MCP: Serving resource metadata'); + res.json(metadata); + }); + + // CORS preflight for MCP endpoint + router.options('/mcp', (req: Request, res: Response) => { + setCORSHeaders(res); + res.status(204).end(); + }); + + // MCP POST handler - main endpoint + // Auth is optional: authenticated users get full tools, anonymous get knowledge tools only + // Rate limited: 20/min anonymous, 100/min authenticated + router.post( + '/mcp', + optionalMcpAuthMiddleware, + mcpRateLimiter, + async (req: MCPAuthenticatedRequest, res: Response) => { + setCORSHeaders(res); + + try { + // Create a new MCP server and transport for each request (stateless mode) + const server = createUnifiedMCPServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // Stateless mode - no sessions + }); + + // Connect server to transport + await server.connect(transport); + + // Log request with auth context + const isAuthenticated = req.mcpAuth?.sub && req.mcpAuth.sub !== 'anonymous'; + logger.debug({ + authenticated: isAuthenticated, + sub: req.mcpAuth?.sub, + method: req.body?.method, + ip: req.ip, + }, 'MCP: Handling request'); + + // Handle the request + await transport.handleRequest(req, res, req.body); + } catch (error) { + logger.error({ error }, 'MCP: Request handling error'); + + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + }); + } + } + } + ); + + // MCP GET handler - not supported in stateless mode + router.get('/mcp', (req: Request, res: Response) => { + setCORSHeaders(res); + res.status(405).json({ + jsonrpc: '2.0', + error: { + code: -32601, + message: 'Method not allowed. Use POST for MCP requests.', + }, + }); + }); + + // MCP DELETE handler - not needed in stateless mode + router.delete('/mcp', (req: Request, res: Response) => { + setCORSHeaders(res); + res.status(405).json({ + jsonrpc: '2.0', + error: { + code: -32601, + message: 'Method not allowed. Session management not supported in stateless mode.', + }, + }); + }); + + logger.info({ authEnabled: MCP_AUTH_ENABLED }, 'MCP: Routes configured'); +} + +/** + * Set CORS headers for MCP endpoint + */ +function setCORSHeaders(res: Response): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); +} diff --git a/server/src/mcp/server.ts b/server/src/mcp/server.ts new file mode 100644 index 0000000000..d390aabcba --- /dev/null +++ b/server/src/mcp/server.ts @@ -0,0 +1,217 @@ +/** + * Unified MCP Server for Addie + * + * Public MCP interface exposing: + * - chat_with_addie: Conversational AI (wraps knowledge + directory tools internally) + * - Directory tools: Programmatic lookup (list_members, list_agents, etc.) + * + * Knowledge and billing tools are NOT exposed directly - they're available + * through chat_with_addie for conversational access, or internal Slack use only. + * + * This is the public MCP interface for: + * - External partners embedding Addie in their apps + * - End users adding Addie to Claude/ChatGPT (OAuth 2.1 auth code flow) + * + * Authentication is handled by WorkOS AuthKit (optional - anonymous allowed). + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { createLogger } from '../logger.js'; +import type { AddieTool } from '../addie/types.js'; + +// Knowledge tools (for initialization check only - not exposed directly) +import { + initializeKnowledgeSearch, + isKnowledgeReady, +} from '../addie/mcp/knowledge-search.js'; + +// Directory tools +import { MCPToolHandler, TOOL_DEFINITIONS, RESOURCE_DEFINITIONS } from '../mcp-tools.js'; + +// Chat tool - conversational AI wrapper (has knowledge + directory tools internally) +import { CHAT_TOOL, createChatToolHandler } from './chat-tool.js'; + +const logger = createLogger('mcp-server'); + +/** + * Convert AddieTool format to MCP SDK tool format + */ +function convertToMCPTool(tool: AddieTool) { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.input_schema, + }; +} + +/** + * All tools available in the unified MCP server + * + * Only exposes: + * - chat_with_addie: Conversational wrapper (uses knowledge + directory tools internally) + * - Directory tools: Programmatic member/agent/publisher lookup + * + * Knowledge and billing tools are NOT exposed - use chat_with_addie instead. + */ +export function getAllTools() { + const chatTool = convertToMCPTool(CHAT_TOOL); + + // Directory tools are already in MCP format + const directoryTools = TOOL_DEFINITIONS; + + return { + directory: directoryTools, + chat: chatTool, + all: [chatTool, ...directoryTools], + }; +} + +/** + * Create all tool handlers + * + * Only creates handlers for publicly exposed tools: + * - chat_with_addie + * - Directory tools (list_members, list_agents, etc.) + */ +function createAllHandlers() { + const handlers = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>(); + + // Directory tool handlers use the existing MCPToolHandler + const directoryHandler = new MCPToolHandler(); + for (const tool of TOOL_DEFINITIONS) { + handlers.set(tool.name, async (args) => { + return directoryHandler.handleToolCall(tool.name, args); + }); + } + + // Chat tool handler (conversational AI wrapper) + const chatHandlers = createChatToolHandler(); + for (const [name, handler] of chatHandlers) { + handlers.set(name, async (args) => { + const result = await handler(args); + return { content: [{ type: 'text', text: result }] }; + }); + } + + return { handlers, directoryHandler }; +} + +/** + * MCP Server instance with lazy initialization + */ +let serverInstance: { + handlers: Map<string, (args: Record<string, unknown>) => Promise<unknown>>; + directoryHandler: MCPToolHandler; +} | null = null; + +function getHandlers() { + if (!serverInstance) { + serverInstance = createAllHandlers(); + } + return serverInstance; +} + +/** + * Create and configure the unified MCP Server + * + * This server exposes Addie capabilities via MCP: + * - chat_with_addie: Conversational AI with knowledge + directory access + * - Directory tools: Programmatic lookup of members, agents, publishers + */ +export function createUnifiedMCPServer(): Server { + const server = new Server( + { + name: 'addie', + version: '1.0.0', + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + } + ); + + const tools = getAllTools(); + const { handlers, directoryHandler } = getHandlers(); + + // List available tools + server.setRequestHandler(ListToolsRequestSchema, async () => { + logger.debug({ toolCount: tools.all.length }, 'MCP: Listing tools'); + return { tools: tools.all }; + }); + + // Handle tool calls + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + logger.debug({ tool: name }, 'MCP: Tool call'); + + const handler = handlers.get(name); + if (!handler) { + logger.warn({ tool: name }, 'MCP: Unknown tool'); + return { + content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], + isError: true, + }; + } + + try { + const result = await handler(args as Record<string, unknown> || {}); + return result as { + content: Array<{ type: string; text?: string; resource?: { uri: string; mimeType: string; text: string } }>; + isError?: boolean; + }; + } catch (error) { + logger.error({ error, tool: name }, 'MCP: Tool execution error'); + return { + content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + }); + + // List available resources (from directory) + server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { resources: RESOURCE_DEFINITIONS }; + }); + + // Read resource contents (from directory) + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + return directoryHandler.handleResourceRead(request.params.uri); + }); + + return server; +} + +/** + * Initialize the MCP server (call at startup) + * + * Note: Knowledge search is shared with Addie and initialized when Addie starts. + * This just pre-creates handlers and verifies readiness. + */ +export async function initializeMCPServer(): Promise<void> { + logger.info('MCP: Initializing unified server...'); + + // Initialize knowledge search if not already done (e.g., in tests or standalone mode) + if (!isKnowledgeReady()) { + await initializeKnowledgeSearch(); + } + + // Pre-create handlers + getHandlers(); + + logger.info({ ready: isKnowledgeReady() }, 'MCP: Unified server ready'); +} + +/** + * Check if MCP server is ready + */ +export function isMCPServerReady(): boolean { + return isKnowledgeReady(); +} From f025a7892580aeefd3d3203768e6c7275483223c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sat, 10 Jan 2026 15:15:48 -0500 Subject: [PATCH 32/77] fix: guard Notification API calls for browser compatibility (#708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: guard Notification.requestPermission call Some browsers (iOS Safari PWA, insecure contexts) expose `Notification` but don't have `requestPermission` as a function. Add typeof check to prevent TypeError. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: guard Notification API calls for browser compatibility Some browsers (iOS Safari PWA, insecure contexts) expose `Notification` but don't have `requestPermission` as a function or may throw when constructing notifications. Add guards to both functions: - requestNotificationPermission: typeof check - notifyUser: typeof check + try-catch for defense in depth 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/polite-candies-rescue.md | 2 ++ server/public/chat.html | 41 ++++++++++++++++++----------- 2 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 .changeset/polite-candies-rescue.md 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/server/public/chat.html b/server/public/chat.html index eefc9919d9..fb5db3f105 100644 --- a/server/public/chat.html +++ b/server/public/chat.html @@ -2636,29 +2636,38 @@ <h2>Hi! I'm Addie</h2> // Request notification permission (opt-in) async function requestNotificationPermission() { - if ('Notification' in window && Notification.permission === 'default') { + if ('Notification' in window && + Notification.permission === 'default' && + typeof Notification.requestPermission === 'function') { await Notification.requestPermission(); } } // Show browser notification function notifyUser(title, body, tabId) { - if ('Notification' in window && Notification.permission === 'granted' && document.hidden) { - const notification = new Notification(title, { - body: body.slice(0, 100) + (body.length > 100 ? '...' : ''), - icon: '/AAo.svg', - tag: tabId // Prevents duplicate notifications - }); - notification.onclick = () => { - window.focus(); - if (tabId && tabId !== 'home') { - const tab = activeTabs.find(t => t.id === tabId); - if (tab) { - switchToConversation(tabId, tab.channel); + if ('Notification' in window && + Notification.permission === 'granted' && + document.hidden && + typeof Notification === 'function') { + try { + const notification = new Notification(title, { + body: body.slice(0, 100) + (body.length > 100 ? '...' : ''), + icon: '/AAo.svg', + tag: tabId // Prevents duplicate notifications + }); + notification.onclick = () => { + window.focus(); + if (tabId && tabId !== 'home') { + const tab = activeTabs.find(t => t.id === tabId); + if (tab) { + switchToConversation(tabId, tab.channel); + } } - } - notification.close(); - }; + notification.close(); + }; + } catch { + // Notifications are non-critical, silently fail + } } } From 7bc5c9867311de74f4611c7048996220c3d6e2e3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 06:36:28 -0500 Subject: [PATCH 33/77] feat: enable Addie to process PDFs, images, and Word documents (#709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enable Addie to process PDFs, images, and Word documents Addie can now read and understand files shared in Slack: - **PDFs**: Passed to Claude as native document content blocks - **Images**: Passed to Claude via vision (multimodal) support - **Word docs (.docx)**: Text extracted via mammoth library Changes: - url-tools.ts: Updated readSlackFile to return FileReadResult with base64-encoded binary data for images/PDFs - claude-client.ts: Process multimodal tool results and convert to proper Claude content blocks (document/image types) - Added mammoth dependency for Word document text extraction When a user shares a PDF or image in Slack and asks Addie about it, she will now download the file and pass it to Claude for analysis instead of saying "I cannot read this file type." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add empty changeset for internal Addie changes * fix: address code review suggestions for file processing - Add streaming size-limited reader to prevent memory exhaustion - Add proper validation for image media types - Add empty file validation for PDFs and images - Add PDF magic byte validation - Add explicit SVG handling with helpful error - Extract duplicated multimodal content block building into helper - Add robust validation in extractMultimodalContent 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/stale-hats-dance.md | 2 + package-lock.json | 190 ++++++++++++++++++- package.json | 1 + server/src/addie/claude-client.ts | 174 ++++++++++++++--- server/src/addie/mcp/url-tools.ts | 303 +++++++++++++++++++++++++++--- 5 files changed, 618 insertions(+), 52 deletions(-) create mode 100644 .changeset/stale-hats-dance.md 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/package-lock.json b/package-lock.json index 5458496d15..094ed2e26c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "jose": "^6.1.3", "jsonwebtoken": "^9.0.3", "linkedom": "^0.18.12", + "mammoth": "^1.11.0", "marked": "^17.0.1", "pg": "^8.16.3", "pino": "^9.14.0", @@ -8607,6 +8608,15 @@ "react-dom": ">=18" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -9514,6 +9524,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -10659,6 +10675,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -11177,6 +11199,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -11302,6 +11330,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -13717,6 +13754,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -15958,6 +16001,54 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -16032,6 +16123,15 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -16193,6 +16293,17 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lowercase-keys": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", @@ -16259,6 +16370,48 @@ "tmpl": "1.0.5" } }, + "node_modules/mammoth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/mammoth/node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -18137,6 +18290,12 @@ "dev": true, "license": "MIT" }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -18395,6 +18554,12 @@ "quansync": "^0.2.7" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -18519,7 +18684,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -19150,6 +19314,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -20861,6 +21031,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -21419,6 +21595,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -22760,6 +22942,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 2d90a2aedb..c04c719dce 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "jose": "^6.1.3", "jsonwebtoken": "^9.0.3", "linkedom": "^0.18.12", + "mammoth": "^1.11.0", "marked": "^17.0.1", "pg": "^8.16.3", "pino": "^9.14.0", diff --git a/server/src/addie/claude-client.ts b/server/src/addie/claude-client.ts index 6ff43c7d71..4dac158c9a 100644 --- a/server/src/addie/claude-client.ts +++ b/server/src/addie/claude-client.ts @@ -12,9 +12,66 @@ import { ADDIE_SYSTEM_PROMPT, buildMessageTurns } from './prompts.js'; import { AddieDatabase, type AddieRule } from '../db/addie-db.js'; import { AddieModelConfig, ModelConfig } from '../config/models.js'; import { getCurrentConfigVersionId, type RuleSnapshot } from './config-version.js'; +import { isMultimodalContent, extractMultimodalContent, isAllowedImageType, type FileReadResult } from './mcp/url-tools.js'; type ToolHandler = (input: Record<string, unknown>) => Promise<string>; +/** + * Build Claude content blocks from multimodal file content. + * Returns null if the content cannot be converted to valid content blocks. + */ +function buildMultimodalContentBlocks( + multimodal: FileReadResult +): { content: Anthropic.ToolResultBlockParam['content']; summary: string } | null { + if (!multimodal.data) { + return null; + } + + const contentBlocks: Anthropic.ToolResultBlockParam['content'] = []; + + if (multimodal.type === 'image') { + // Validate media type before using + if (!isAllowedImageType(multimodal.media_type)) { + logger.warn( + { mediaType: multimodal.media_type }, + 'Addie: Invalid image media type in multimodal content' + ); + return null; + } + contentBlocks.push({ + type: 'image', + source: { + type: 'base64', + media_type: multimodal.media_type, + data: multimodal.data, + }, + }); + contentBlocks.push({ + type: 'text', + text: `[Image: ${multimodal.filename || 'uploaded image'}]`, + }); + } else if (multimodal.type === 'document') { + contentBlocks.push({ + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: multimodal.data, + }, + }); + contentBlocks.push({ + type: 'text', + text: `[PDF Document: ${multimodal.filename || 'uploaded document'}]`, + }); + } else { + // Unknown multimodal type + return null; + } + + const summary = `Loaded ${multimodal.type}: ${multimodal.filename || 'file'}`; + return { content: contentBlocks, summary }; +} + /** Default max tool iterations for regular users */ export const DEFAULT_MAX_ITERATIONS = 10; @@ -525,9 +582,11 @@ export class AddieClaudeClient { }; } + // Tool results can contain multimodal content (images, PDFs) + type ToolResultContent = string | Anthropic.ToolResultBlockParam['content']; interface ToolResult { tool_use_id: string; - content: string; + content: ToolResultContent; is_error?: boolean; } @@ -567,16 +626,49 @@ export class AddieClaudeClient { try { const result = await handler(toolInput); const durationMs = Date.now() - startTime; - toolResults.push({ tool_use_id: toolUseId, content: result }); - toolExecutions.push({ - tool_name: toolName, - parameters: toolInput, - result, - result_summary: this.summarizeToolResult(toolName, result), - is_error: false, - duration_ms: durationMs, - sequence: executionSequence, - }); + + // Check if result contains multimodal content (images, PDFs) + if (isMultimodalContent(result)) { + const multimodal = extractMultimodalContent(result); + const multimodalBlocks = multimodal ? buildMultimodalContentBlocks(multimodal) : null; + + if (multimodalBlocks) { + toolResults.push({ tool_use_id: toolUseId, content: multimodalBlocks.content }); + toolExecutions.push({ + tool_name: toolName, + parameters: toolInput, + result: multimodalBlocks.summary, + result_summary: multimodalBlocks.summary, + is_error: false, + duration_ms: durationMs, + sequence: executionSequence, + }); + logger.info({ toolName, multimodalType: multimodal?.type, filename: multimodal?.filename }, 'Addie: Processed multimodal tool result'); + } else { + // Failed to parse or validate multimodal content + toolResults.push({ tool_use_id: toolUseId, content: 'Error: Failed to process file content' }); + toolExecutions.push({ + tool_name: toolName, + parameters: toolInput, + result: 'Error: Failed to process file content', + is_error: true, + duration_ms: durationMs, + sequence: executionSequence, + }); + } + } else { + // Regular text result + toolResults.push({ tool_use_id: toolUseId, content: result }); + toolExecutions.push({ + tool_name: toolName, + parameters: toolInput, + result, + result_summary: this.summarizeToolResult(toolName, result), + is_error: false, + duration_ms: durationMs, + sequence: executionSequence, + }); + } } catch (error) { const durationMs = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -823,9 +915,11 @@ export class AddieClaudeClient { return; } + // Tool results can contain multimodal content (images, PDFs) + type StreamToolResultContent = string | Anthropic.ToolResultBlockParam['content']; interface ToolResult { tool_use_id: string; - content: string; + content: StreamToolResultContent; is_error?: boolean; } @@ -870,17 +964,51 @@ export class AddieClaudeClient { try { const result = await handler(toolInput); const durationMs = Date.now() - startTime; - toolResults.push({ tool_use_id: toolUseId, content: result }); - toolExecutions.push({ - tool_name: toolName, - parameters: toolInput, - result, - result_summary: this.summarizeToolResult(toolName, result), - is_error: false, - duration_ms: durationMs, - sequence: executionSequence, - }); - yield { type: 'tool_end', tool_name: toolName, result, is_error: false }; + + // Check if result contains multimodal content (images, PDFs) + if (isMultimodalContent(result)) { + const multimodal = extractMultimodalContent(result); + const multimodalBlocks = multimodal ? buildMultimodalContentBlocks(multimodal) : null; + + if (multimodalBlocks) { + toolResults.push({ tool_use_id: toolUseId, content: multimodalBlocks.content }); + toolExecutions.push({ + tool_name: toolName, + parameters: toolInput, + result: multimodalBlocks.summary, + result_summary: multimodalBlocks.summary, + is_error: false, + duration_ms: durationMs, + sequence: executionSequence, + }); + yield { type: 'tool_end', tool_name: toolName, result: multimodalBlocks.summary, is_error: false }; + logger.info({ toolName, multimodalType: multimodal?.type, filename: multimodal?.filename }, 'Addie Stream: Processed multimodal tool result'); + } else { + toolResults.push({ tool_use_id: toolUseId, content: 'Error: Failed to process file content' }); + toolExecutions.push({ + tool_name: toolName, + parameters: toolInput, + result: 'Error: Failed to process file content', + is_error: true, + duration_ms: durationMs, + sequence: executionSequence, + }); + yield { type: 'tool_end', tool_name: toolName, result: 'Error: Failed to process file content', is_error: true }; + } + } else { + // Regular text result + toolResults.push({ tool_use_id: toolUseId, content: result }); + toolExecutions.push({ + tool_name: toolName, + parameters: toolInput, + result, + result_summary: this.summarizeToolResult(toolName, result), + is_error: false, + duration_ms: durationMs, + sequence: executionSequence, + }); + yield { type: 'tool_end', tool_name: toolName, result, is_error: false }; + } } catch (error) { const durationMs = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; diff --git a/server/src/addie/mcp/url-tools.ts b/server/src/addie/mcp/url-tools.ts index 844d9e7cd6..887e9ac787 100644 --- a/server/src/addie/mcp/url-tools.ts +++ b/server/src/addie/mcp/url-tools.ts @@ -11,12 +11,85 @@ import { logger } from '../../logger.js'; import type { AddieTool } from '../types.js'; -// Maximum content size to prevent memory issues (500KB) +// Maximum content size to prevent memory issues (500KB for text, 20MB for images/PDFs) const MAX_CONTENT_SIZE = 500 * 1024; +const MAX_BINARY_SIZE = 20 * 1024 * 1024; // 20MB for images/PDFs (Claude's limit) // Maximum time to wait for fetch (10 seconds) const FETCH_TIMEOUT_MS = 10000; +// Allowed image types for Claude's vision API +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const; +export type AllowedImageType = (typeof ALLOWED_IMAGE_TYPES)[number]; + +/** + * Check if a media type is allowed for image processing + */ +export function isAllowedImageType(type: string | undefined): type is AllowedImageType { + return type !== undefined && ALLOWED_IMAGE_TYPES.includes(type as AllowedImageType); +} + +/** + * Result from reading a file - can be text or multimodal content + */ +export interface FileReadResult { + type: 'text' | 'image' | 'document'; + /** For text type: the text content */ + text?: string; + /** For image/document type: base64-encoded data */ + data?: string; + /** MIME type for binary content */ + media_type?: string; + /** Original filename */ + filename?: string; + /** Error message if failed */ + error?: string; +} + +/** + * Read binary response body with streaming size limit protection. + * This prevents memory exhaustion if content-length header is missing or spoofed. + */ +async function readBinaryWithSizeLimit( + response: Response, + maxSize: number, + fileType: string +): Promise<ArrayBuffer | { error: string }> { + const reader = response.body?.getReader(); + + if (!reader) { + return { error: 'Could not read response body' }; + } + + const chunks: Uint8Array[] = []; + let totalSize = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalSize += value.length; + if (totalSize > maxSize) { + reader.cancel(); + return { error: `${fileType} exceeded ${Math.round(maxSize / 1024 / 1024)}MB limit during download` }; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + // Combine chunks into single ArrayBuffer + const combined = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + return combined.buffer; +} + /** * Tool definitions for URL/file fetching */ @@ -262,19 +335,20 @@ function convertHtmlToMarkdown(html: string): string { /** * Read a file from Slack using the bot token + * Returns structured data for multimodal content (images, PDFs) */ async function readSlackFile( fileUrl: string, fileName?: string, botToken?: string -): Promise<string> { +): Promise<FileReadResult> { if (!botToken) { - return 'Error: Slack bot token not available for file access'; + return { type: 'text', error: 'Slack bot token not available for file access' }; } // Validate it's a Slack URL if (!fileUrl.includes('slack.com') && !fileUrl.includes('files.slack.com')) { - return 'Error: Not a valid Slack file URL'; + return { type: 'text', error: 'Not a valid Slack file URL' }; } try { @@ -293,21 +367,16 @@ async function readSlackFile( if (!response.ok) { if (response.status === 404) { - return 'Error: File not found or no longer available'; + return { type: 'text', error: 'File not found or no longer available' }; } if (response.status === 403) { - return 'Error: Access denied - bot may not have permission to access this file'; + return { type: 'text', error: 'Access denied - bot may not have permission to access this file' }; } - return `Error: HTTP ${response.status} ${response.statusText}`; - } - - // Check content length - const contentLength = response.headers.get('content-length'); - if (contentLength && parseInt(contentLength) > MAX_CONTENT_SIZE) { - return `Error: File too large (${Math.round(parseInt(contentLength) / 1024)}KB > ${MAX_CONTENT_SIZE / 1024}KB limit)`; + return { type: 'text', error: `HTTP ${response.status} ${response.statusText}` }; } const contentType = response.headers.get('content-type') || ''; + const contentLength = response.headers.get('content-length'); const ext = fileName?.split('.').pop()?.toLowerCase() || ''; // Handle text-based files @@ -317,36 +386,166 @@ async function readSlackFile( contentType.includes('application/xml') || ['txt', 'md', 'json', 'xml', 'csv', 'log', 'yaml', 'yml', 'html', 'htm', 'js', 'ts', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'sql'].includes(ext) ) { + // Check size for text files + if (contentLength && parseInt(contentLength) > MAX_CONTENT_SIZE) { + return { type: 'text', error: `File too large (${Math.round(parseInt(contentLength) / 1024)}KB > ${MAX_CONTENT_SIZE / 1024}KB limit)` }; + } const text = await response.text(); if (text.length > MAX_CONTENT_SIZE) { - return `File content (first ${MAX_CONTENT_SIZE / 1024}KB):\n\n${text.substring(0, MAX_CONTENT_SIZE)}...\n\n[Content truncated]`; + return { + type: 'text', + text: `File content (first ${MAX_CONTENT_SIZE / 1024}KB):\n\n${text.substring(0, MAX_CONTENT_SIZE)}...\n\n[Content truncated]`, + filename: fileName, + }; } - return `File content:\n\n${text}`; + return { type: 'text', text: `File content:\n\n${text}`, filename: fileName }; } - // Handle PDF - we can't read these directly, but we can acknowledge them + // Handle PDF - return as document for Claude's native PDF support if (contentType.includes('application/pdf') || ext === 'pdf') { - return `This is a PDF file (${fileName || 'unnamed'}). I cannot read PDF content directly, but I can see it was shared. If you need me to understand the content, please copy and paste the relevant text.`; + // Use streaming reader for size-safe download + const bufferResult = await readBinaryWithSizeLimit(response, MAX_BINARY_SIZE, 'PDF'); + if ('error' in bufferResult) { + return { type: 'text', error: bufferResult.error }; + } + if (bufferResult.byteLength === 0) { + return { type: 'text', error: 'PDF file is empty' }; + } + // Basic PDF magic byte validation + const uint8 = new Uint8Array(bufferResult); + if (uint8.length < 4 || String.fromCharCode(uint8[0], uint8[1], uint8[2], uint8[3]) !== '%PDF') { + return { type: 'text', error: 'File does not appear to be a valid PDF' }; + } + const base64 = Buffer.from(bufferResult).toString('base64'); + logger.info({ fileName, size: bufferResult.byteLength }, 'Addie: Loaded PDF for multimodal processing'); + return { + type: 'document', + data: base64, + media_type: 'application/pdf', + filename: fileName, + }; + } + + // Handle SVG explicitly - not supported for vision but give helpful message + if (contentType.includes('image/svg') || ext === 'svg') { + return { + type: 'text', + error: 'SVG files are not supported for visual processing. If you need the SVG content analyzed, please share it as text.', + }; + } + + // Handle images - return as image for Claude's vision + if (contentType.includes('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) { + // Use streaming reader for size-safe download + const bufferResult = await readBinaryWithSizeLimit(response, MAX_BINARY_SIZE, 'Image'); + if ('error' in bufferResult) { + return { type: 'text', error: bufferResult.error }; + } + if (bufferResult.byteLength === 0) { + return { type: 'text', error: 'Image file is empty' }; + } + const base64 = Buffer.from(bufferResult).toString('base64'); + // Determine proper media type + let mediaType = contentType.split(';')[0].trim(); + if (!mediaType.startsWith('image/')) { + // Infer from extension + const extToMime: Record<string, string> = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + }; + mediaType = extToMime[ext] || 'image/png'; + } + // Validate the media type is supported + if (!isAllowedImageType(mediaType)) { + return { + type: 'text', + error: `Image format '${mediaType}' is not supported. Supported formats: JPEG, PNG, GIF, WebP.`, + }; + } + logger.info({ fileName, size: bufferResult.byteLength, mediaType }, 'Addie: Loaded image for vision processing'); + return { + type: 'image', + data: base64, + media_type: mediaType, + filename: fileName, + }; } - // Handle images - acknowledge but can't read - if (contentType.includes('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) { - return `This is an image file (${fileName || 'unnamed'}). I can see it was shared but cannot view image contents directly.`; + // Handle Word documents - extract text using mammoth (if available) or return error + if ( + contentType.includes('application/vnd.openxmlformats-officedocument.wordprocessingml.document') || + contentType.includes('application/msword') || + ['docx', 'doc'].includes(ext) + ) { + // Use streaming reader for size-safe download + const bufferResult = await readBinaryWithSizeLimit(response, MAX_BINARY_SIZE, 'Word document'); + if ('error' in bufferResult) { + return { type: 'text', error: bufferResult.error }; + } + if (bufferResult.byteLength === 0) { + return { type: 'text', error: 'Word document is empty' }; + } + try { + // Try to import mammoth dynamically + const mammoth = await import('mammoth'); + const result = await mammoth.extractRawText({ buffer: Buffer.from(bufferResult) }); + logger.info({ fileName, textLength: result.value.length }, 'Addie: Extracted text from Word document'); + return { + type: 'text', + text: `Word document content:\n\n${result.value}`, + filename: fileName, + }; + } catch (mammothError) { + logger.warn({ error: mammothError, fileName }, 'Addie: mammoth not available for Word doc extraction'); + return { + type: 'text', + error: `This is a Word document (${fileName || 'unnamed'}). Word document processing is not currently available. Please copy and paste the relevant text.`, + }; + } } // Handle other binary files - return `This is a ${contentType || 'binary'} file (${fileName || 'unnamed'}). I cannot read the contents of this file type directly.`; + return { + type: 'text', + error: `This is a ${contentType || 'binary'} file (${fileName || 'unnamed'}). I cannot read the contents of this file type directly.`, + }; } catch (error) { if (error instanceof Error) { if (error.name === 'AbortError') { - return `Error: Request timed out after ${FETCH_TIMEOUT_MS / 1000} seconds`; + return { type: 'text', error: `Request timed out after ${FETCH_TIMEOUT_MS / 1000} seconds` }; } - return `Error: ${error.message}`; + return { type: 'text', error: error.message }; } - return 'Error: Unknown error reading file'; + return { type: 'text', error: 'Unknown error reading file' }; } } +/** + * Format a FileReadResult as a string for the tool response + * For multimodal content, returns a special marker that the handler will process + */ +function formatFileResult(result: FileReadResult): string { + if (result.error) { + return `Error: ${result.error}`; + } + + if (result.type === 'text') { + return result.text || 'No content'; + } + + // For images and documents, return a special JSON marker that the message handler will process + // This allows the multimodal content to be passed to Claude as proper content blocks + return `__MULTIMODAL_CONTENT__${JSON.stringify({ + type: result.type, + data: result.data, + media_type: result.media_type, + filename: result.filename, + })}__END_MULTIMODAL__`; +} + /** * Create tool handlers for URL/file fetching */ @@ -375,13 +574,61 @@ export function createUrlToolHandlers(slackBotToken?: string): Record<string, (i logger.info({ fileUrl, fileName }, 'Addie: Reading Slack file'); const result = await readSlackFile(fileUrl, fileName, slackBotToken); + const formatted = formatFileResult(result); - // Truncate if too long - if (result.length > 10000) { - return result.substring(0, 10000) + '\n\n[Content truncated to 10,000 characters]'; + // Only truncate text content, not multimodal markers + if (!formatted.startsWith('__MULTIMODAL_CONTENT__') && formatted.length > 10000) { + return formatted.substring(0, 10000) + '\n\n[Content truncated to 10,000 characters]'; } - return result; + return formatted; }, }; } + +/** + * Check if a tool result contains multimodal content + */ +export function isMultimodalContent(content: string): boolean { + return content.startsWith('__MULTIMODAL_CONTENT__') && content.includes('__END_MULTIMODAL__'); +} + +/** + * Extract and validate multimodal content from a tool result. + * Returns null if the content is malformed or missing required fields. + */ +export function extractMultimodalContent(content: string): FileReadResult | null { + if (!isMultimodalContent(content)) { + return null; + } + try { + const jsonStr = content.replace('__MULTIMODAL_CONTENT__', '').replace('__END_MULTIMODAL__', ''); + const parsed = JSON.parse(jsonStr); + + // Validate required fields exist + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const { type, data, media_type, filename } = parsed; + + // Type must be a valid FileReadResult type + if (type !== 'text' && type !== 'image' && type !== 'document') { + return null; + } + + // For image/document types, data is required + if ((type === 'image' || type === 'document') && typeof data !== 'string') { + return null; + } + + return { + type, + data: typeof data === 'string' ? data : undefined, + media_type: typeof media_type === 'string' ? media_type : undefined, + filename: typeof filename === 'string' ? filename : undefined, + }; + } catch { + return null; + } +} From d51e28a7d0eeb797c8dc92c16958ead6f881dd91 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 11:15:12 -0500 Subject: [PATCH 34/77] feat: require OAuth authentication for MCP endpoint (#710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch MCP endpoint from optional auth to required auth. Unauthenticated requests now receive 401 with OAuth discovery metadata, allowing MCP clients (Claude Desktop, etc.) to initiate the OAuth flow via WorkOS AuthKit with CIMD support. Changes: - Use mcpAuthMiddleware instead of optionalMcpAuthMiddleware - Simplify rate limiter (no more anonymous path) - Remove unused optionalMcpAuthMiddleware function - Update documentation to reflect required auth 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/open-squids-flow.md | 2 ++ server/src/mcp/auth.ts | 54 ++++++---------------------------- server/src/mcp/index.ts | 7 ++--- server/src/mcp/routes.ts | 35 +++++++--------------- 4 files changed, 25 insertions(+), 73 deletions(-) create mode 100644 .changeset/open-squids-flow.md 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/server/src/mcp/auth.ts b/server/src/mcp/auth.ts index 0b9a8c820a..94f9eb0e0e 100644 --- a/server/src/mcp/auth.ts +++ b/server/src/mcp/auth.ts @@ -1,23 +1,22 @@ /** * MCP Authentication Middleware * - * Supports two access modes: + * Authentication required via OAuth 2.1 (WorkOS AuthKit). + * Unauthenticated requests receive 401 with OAuth discovery metadata. * - * 1. Authenticated (OAuth 2.1) - * - User adds Addie to Claude Desktop or ChatGPT - * - Redirected to WorkOS login, gets access token - * - Full tools available based on membership status - * - * 2. Anonymous - * - No authentication required - * - Knowledge tools only (search_docs, search_repos, etc.) - * - Rate limited by IP address + * Flow: + * - User adds Addie to Claude Desktop or ChatGPT + * - Client discovers auth server via /.well-known/oauth-protected-resource + * - User redirected to WorkOS login, gets access token + * - Client retries with bearer token * * JWT validation via WorkOS JWKS: * - Token signature verification * - Issuer matches AuthKit * - Expiry (exp) and not-before (nbf) claims * - Audience matches resource identifier + * + * Can be disabled via MCP_AUTH_DISABLED=true for local development. */ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; @@ -206,41 +205,6 @@ export async function mcpAuthMiddleware( } } -/** - * Optional MCP auth middleware - * - * Like mcpAuthMiddleware but allows unauthenticated requests. - * Use for endpoints that have both public and authenticated features. - */ -export async function optionalMcpAuthMiddleware( - req: MCPAuthenticatedRequest, - res: Response, - next: NextFunction -): Promise<void> { - if (!MCP_AUTH_ENABLED) { - next(); - return; - } - - const token = extractBearerToken(req); - - if (!token) { - // No token, continue without auth context - next(); - return; - } - - try { - const authContext = await validateToken(token); - req.mcpAuth = authContext; - next(); - } catch (error) { - // Invalid token - still continue but without auth - logger.debug({ error }, 'MCP Auth: Optional token validation failed'); - next(); - } -} - /** * Get the resource metadata URL for this server */ diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 9ff5796d4e..acd6cc048f 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -6,15 +6,14 @@ * - Directory lookup (members, agents, publishers) * - Billing operations (membership products, payment links) * - * Access modes: - * - Authenticated (OAuth 2.1): Full tools based on membership (10/min) - * - Anonymous: Knowledge tools only, rate limited by IP (5/min) + * Authentication required via OAuth 2.1 (WorkOS AuthKit). + * Unauthenticated requests receive 401 with OAuth discovery metadata. */ export { createUnifiedMCPServer, initializeMCPServer, isMCPServerReady, getAllTools } from './server.js'; export { configureMCPRoutes } from './routes.js'; export { - optionalMcpAuthMiddleware, + mcpAuthMiddleware, getOAuthProtectedResourceMetadata, getAuthorizationServerMetadataUrl, MCP_AUTH_ENABLED, diff --git a/server/src/mcp/routes.ts b/server/src/mcp/routes.ts index 27302838af..3ba3c144f8 100644 --- a/server/src/mcp/routes.ts +++ b/server/src/mcp/routes.ts @@ -2,23 +2,22 @@ * MCP Route Handlers * * Configures Express routes for the unified MCP server: - * - POST /mcp - MCP JSON-RPC endpoint (optional auth, rate limited) + * - POST /mcp - MCP JSON-RPC endpoint (auth required, rate limited) * - GET /.well-known/oauth-protected-resource - OAuth resource metadata * - OPTIONS /mcp - CORS preflight * - * Access modes: - * - Authenticated: Full tools based on membership - * - Anonymous: Knowledge tools only, rate limited by IP + * Authentication required via OAuth 2.1 (WorkOS AuthKit). + * Unauthenticated requests receive 401 with OAuth discovery metadata, + * allowing MCP clients to initiate the OAuth flow. */ import type { Router, Request, Response } from 'express'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import rateLimit from 'express-rate-limit'; -import { ipKeyGenerator } from 'express-rate-limit'; import { createLogger } from '../logger.js'; import { createUnifiedMCPServer } from './server.js'; import { - optionalMcpAuthMiddleware, + mcpAuthMiddleware, getOAuthProtectedResourceMetadata, MCP_AUTH_ENABLED, type MCPAuthenticatedRequest, @@ -28,27 +27,15 @@ const logger = createLogger('mcp-routes'); /** * Rate limiter for MCP endpoint - * Anonymous: 5 requests per minute per IP - * Authenticated: 10 requests per minute per user + * 10 requests per minute per authenticated user */ const mcpRateLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute - max: (req: MCPAuthenticatedRequest) => { - // Authenticated users get slightly higher limits - if (req.mcpAuth?.sub && req.mcpAuth.sub !== 'anonymous') { - return 10; // 10 requests per minute for authenticated users - } - return 5; // 5 requests per minute for anonymous - }, + max: 10, standardHeaders: true, legacyHeaders: false, keyGenerator: (req: MCPAuthenticatedRequest) => { - // Use user ID for authenticated, IP for anonymous - if (req.mcpAuth?.sub && req.mcpAuth.sub !== 'anonymous') { - return `user:${req.mcpAuth.sub}`; - } - // Use ipKeyGenerator helper to handle IPv6 properly - return ipKeyGenerator(req.ip || 'unknown'); + return `user:${req.mcpAuth?.sub || 'anonymous'}`; }, handler: (req, res) => { res.status(429).json({ @@ -80,11 +67,11 @@ export function configureMCPRoutes(router: Router): void { }); // MCP POST handler - main endpoint - // Auth is optional: authenticated users get full tools, anonymous get knowledge tools only - // Rate limited: 20/min anonymous, 100/min authenticated + // Auth required: returns 401 with OAuth discovery metadata for unauthenticated requests + // Rate limited: 10/min per user router.post( '/mcp', - optionalMcpAuthMiddleware, + mcpAuthMiddleware, mcpRateLimiter, async (req: MCPAuthenticatedRequest, res: Response) => { setCORSHeaders(res); From 0879ecfc1cdb3bc3819f352c327f919efb4aca2e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 11:45:44 -0500 Subject: [PATCH 35/77] fix: content submission flow and pending content 500 error (#711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: content routes 500 error from invalid SQL query Two issues in the /api/content/pending endpoint: 1. The isAdmin() function queried a non-existent column `is_aao_admin` on the users table, causing SQL errors. Fixed by using the existing isWebUserAdmin() function from admin-tools.ts that correctly checks aao-admin working group membership. 2. The SQL JOIN used `u.id` instead of `u.workos_user_id` to match the users table primary key. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: content submission flow and notifications 1. Fix 500 error on /api/content/pending: - Replace broken isAdmin() that queried non-existent column - Use isWebUserAdmin() which checks aao-admin working group membership - Fix SQL JOIN to use workos_user_id instead of id 2. Add Slack notification when content is submitted for review: - Notify working group's Slack channel with title, author, and review link - Fire-and-forget to avoid blocking the response 3. Fix Addie's propose_content tool: - Default to 'editorial' collection when type is 'personal' or not specified - This ensures perspective articles go to the Editorial working group - Update response messages to show "Perspectives" for editorial collection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add empty changeset 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address code review feedback - Fix isCommitteeLead() to handle users added via Slack ID by joining with slack_user_mappings table (matches pattern used elsewhere) - Fix getUserInfo() to query by workos_user_id instead of id - Add committeeId and authorName to error logging context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/tender-jeans-double.md | 2 + server/src/addie/mcp/member-tools.ts | 21 +++--- server/src/routes/content.ts | 98 ++++++++++++++++++++++------ 3 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 .changeset/tender-jeans-double.md 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/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index c0f35ddc32..b8f189d69a 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -1341,9 +1341,14 @@ export function createMemberToolHandlers( const externalUrl = input.external_url as string | undefined; const excerpt = input.excerpt as string | undefined; const category = input.category as string | undefined; - const collection = input.collection as { type: string; committee_slug?: string }; + const inputCollection = input.collection as { type: string; committee_slug?: string } | undefined; const coAuthorEmails = input.co_author_emails as string[] | undefined; + // Default to editorial collection for personal/perspectives content + const collection = inputCollection?.type === 'personal' || !inputCollection + ? { type: 'committee', committee_slug: 'editorial' } + : inputCollection; + // Validate requirements if (contentType === 'article' && !contentBody) { return 'Content is required for article type. Please provide the content in markdown format.'; @@ -1387,20 +1392,20 @@ export function createMemberToolHandlers( response += `**Title:** ${title}\n`; response += `**Status:** ${data.status === 'published' ? '✅ Published' : '⏳ Pending Review'}\n`; - if (collection.type === 'committee') { - response += `**Collection:** ${collection.committee_slug}\n`; + if (collection.committee_slug === 'editorial') { + response += `**Collection:** Perspectives\n`; } else { - response += `**Collection:** Personal (perspectives)\n`; + response += `**Collection:** ${collection.committee_slug}\n`; } if (data.status === 'published') { - if (collection.type === 'committee') { - response += `\n**View:** https://agenticadvertising.org/committees/${collection.committee_slug}\n`; - } else { + if (collection.committee_slug === 'editorial') { response += `\n**View:** https://agenticadvertising.org/perspectives/${data.slug}\n`; + } else { + response += `\n**View:** https://agenticadvertising.org/committees/${collection.committee_slug}\n`; } } else { - response += `\n_A committee lead or admin will review your submission. You'll be notified when it's approved._\n`; + response += `\n_An admin will review your submission. You'll be notified when it's approved._\n`; } if (coAuthorEmails && coAuthorEmails.length > 0) { diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts index 598bd4c909..c6adc382c2 100644 --- a/server/src/routes/content.ts +++ b/server/src/routes/content.ts @@ -12,6 +12,8 @@ import { Router } from 'express'; import { createLogger } from '../logger.js'; import { requireAuth } from '../middleware/auth.js'; import { getPool } from '../db/client.js'; +import { isWebUserAdmin } from '../addie/mcp/admin-tools.js'; +import { sendChannelMessage } from '../slack/client.js'; const logger = createLogger('content-routes'); @@ -40,27 +42,73 @@ interface ProposeContentRequest { } /** - * Check if user is a committee lead + * Notify a working group's Slack channel about pending content */ -async function isCommitteeLead(committeeId: string, userId: string): Promise<boolean> { +async function notifyWorkingGroupOfPendingContent( + workingGroupId: string, + perspective: { id: string; title: string; slug: string }, + authorName: string +): Promise<void> { const pool = getPool(); - const result = await pool.query( - `SELECT 1 FROM working_group_leaders - WHERE working_group_id = $1 AND user_id = $2`, - [committeeId, userId] + + // Get the working group's Slack channel + const wgResult = await pool.query( + `SELECT name, slack_channel_id FROM working_groups WHERE id = $1`, + [workingGroupId] ); - return result.rows.length > 0; + + if (wgResult.rows.length === 0 || !wgResult.rows[0].slack_channel_id) { + logger.debug({ workingGroupId }, 'Working group has no Slack channel, skipping notification'); + return; + } + + const { name: wgName, slack_channel_id: slackChannelId } = wgResult.rows[0]; + + try { + await sendChannelMessage(slackChannelId, { + text: `New content pending review: "${perspective.title}" by ${authorName}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `📝 *New content submitted for review*\n\n*Title:* ${perspective.title}\n*Author:* ${authorName}\n*Collection:* ${wgName}`, + }, + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: 'Review Content', + emoji: true, + }, + url: `https://agenticadvertising.org/my-content.html`, + action_id: 'review_content', + }, + ], + }, + ], + }); + + logger.info({ workingGroupId, perspectiveId: perspective.id, slackChannelId }, 'Sent pending content notification to working group'); + } catch (error) { + logger.error({ error, workingGroupId, perspectiveId: perspective.id }, 'Failed to send pending content notification'); + } } /** - * Check if user is a site admin + * Check if user is a committee lead (handles both WorkOS and Slack user IDs) */ -async function isAdmin(userId: string): Promise<boolean> { +async function isCommitteeLead(committeeId: string, userId: string): Promise<boolean> { const pool = getPool(); const result = await pool.query( - `SELECT 1 FROM users - WHERE id = $1 AND is_aao_admin = true`, - [userId] + `SELECT 1 FROM working_group_leaders wgl + LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL + WHERE wgl.working_group_id = $1 AND (wgl.user_id = $2 OR sm.workos_user_id = $2)`, + [committeeId, userId] ); return result.rows.length > 0; } @@ -71,7 +119,7 @@ async function isAdmin(userId: string): Promise<boolean> { async function getUserInfo(userId: string): Promise<{ name: string; title?: string } | null> { const pool = getPool(); const result = await pool.query( - `SELECT first_name, last_name, title, email FROM users WHERE id = $1`, + `SELECT first_name, last_name, title, email FROM users WHERE workos_user_id = $1`, [userId] ); if (result.rows.length === 0) return null; @@ -216,7 +264,7 @@ export function createContentRouter(): Router { // Check if user can submit to this collection const userIsLead = await isCommitteeLead(committeeId, user.id); - const userIsAdmin = await isAdmin(user.id); + const userIsAdmin = await isWebUserAdmin(user.id); // For non-public collections, user must be a member if (!acceptsPublicSubmissions && !userIsLead && !userIsAdmin) { @@ -298,6 +346,14 @@ export function createContentRouter(): Router { committeeSlug: collection.committee_slug, }, 'Content proposed'); + // Notify working group if content needs review + if (status === 'pending_review') { + // Fire and forget - don't block the response + notifyWorkingGroupOfPendingContent(committeeId, perspective, authorName).catch(err => { + logger.error({ err, perspectiveId: perspective.id, committeeId, authorName }, 'Failed to send content notification'); + }); + } + const message = canPublishDirectly ? 'Content published successfully' : 'Content submitted for review. A committee lead or admin will review it soon.'; @@ -338,7 +394,7 @@ export function createContentRouter(): Router { const ledCommitteeIds = ledCommittees.map(c => c.id); // Check if admin - const userIsAdmin = await isAdmin(user.id); + const userIsAdmin = await isWebUserAdmin(user.id); if (!userIsAdmin && ledCommitteeIds.length === 0) { return res.json({ @@ -361,7 +417,7 @@ export function createContentRouter(): Router { FROM content_authors ca WHERE ca.perspective_id = p.id) as authors FROM perspectives p LEFT JOIN working_groups wg ON wg.id = p.working_group_id - LEFT JOIN users u ON u.id = p.proposer_user_id + LEFT JOIN users u ON u.workos_user_id = p.proposer_user_id WHERE p.status = 'pending_review' `; const params: (string | string[])[] = []; @@ -460,7 +516,7 @@ export function createContentRouter(): Router { } // Check permission - const userIsAdmin = await isAdmin(user.id); + const userIsAdmin = await isWebUserAdmin(user.id); const userIsLead = content.working_group_id ? await isCommitteeLead(content.working_group_id, user.id) : false; @@ -635,7 +691,7 @@ export function createContentRouter(): Router { } // Check permission - const userIsAdmin = await isAdmin(user.id); + const userIsAdmin = await isWebUserAdmin(user.id); const userIsLead = content.working_group_id ? await isCommitteeLead(content.working_group_id, user.id) : false; @@ -848,7 +904,7 @@ export function createMyContentRouter(): Router { const userIsLead = contentItem.working_group_id ? await isCommitteeLead(contentItem.working_group_id, user.id) : false; - const userIsAdmin = await isAdmin(user.id); + const userIsAdmin = await isWebUserAdmin(user.id); if (!isProposer && !isAuthor && !userIsLead && !userIsAdmin) { return res.status(403).json({ @@ -960,7 +1016,7 @@ export function createMyContentRouter(): Router { const userIsLead = contentItem.working_group_id ? await isCommitteeLead(contentItem.working_group_id, user.id) : false; - const userIsAdmin = await isAdmin(user.id); + const userIsAdmin = await isWebUserAdmin(user.id); if (!isProposer && !userIsLead && !userIsAdmin) { return res.status(403).json({ @@ -1030,7 +1086,7 @@ export function createMyContentRouter(): Router { const userIsLead = contentItem.working_group_id ? await isCommitteeLead(contentItem.working_group_id, user.id) : false; - const userIsAdmin = await isAdmin(user.id); + const userIsAdmin = await isWebUserAdmin(user.id); if (!isProposer && !userIsLead && !userIsAdmin) { return res.status(403).json({ From cf316e993538974a8c197d4ad82218ec56b8c9d3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 11:51:07 -0500 Subject: [PATCH 36/77] feat: update Addie's core thesis to allocation vs efficiency framing (#712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Addie's understanding of AdCP's strategic positioning: - Core thesis: Advertisers stuck at 3-5 platforms due to execution costs; the opportunity is scaling to 20+ partners, not optimizing the existing few - Walled gardens benefit from AdCP (capture new allocation budgets without commoditizing differentiation) rather than being threatened by it - Day trading vs investing analogy: RTB reasons about impressions in real-time; AdCP reasons about budgets, relationships, and outcomes Changes: - Updated content-curator examples to correctly frame walled garden relationship - Updated main system prompt with the allocation thesis - Added new operating rule for allocation vs efficiency knowledge - Updated Core Mission and Programmatic/OpenRTB rules with strategic framing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/every-falcons-peel.md | 4 ++ server/src/addie/prompts.ts | 6 +- server/src/addie/services/content-curator.ts | 3 +- .../157_allocation_vs_efficiency_rule.sql | 32 ++++++++++ ..._update_core_mission_allocation_thesis.sql | 60 +++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 .changeset/every-falcons-peel.md create mode 100644 server/src/db/migrations/157_allocation_vs_efficiency_rule.sql create mode 100644 server/src/db/migrations/158_update_core_mission_allocation_thesis.sql 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/server/src/addie/prompts.ts b/server/src/addie/prompts.ts index e67b4f84c5..a9083087a6 100644 --- a/server/src/addie/prompts.ts +++ b/server/src/addie/prompts.ts @@ -9,7 +9,11 @@ import { getCachedActiveGoals } from './insights-cache.js'; const logger = createLogger('addie-prompts'); -export const ADDIE_SYSTEM_PROMPT = `You are Addie, the AI assistant for AgenticAdvertising.org. Your mission is to help the ad tech industry transition from programmatic to agentic advertising. +export const ADDIE_SYSTEM_PROMPT = `You are Addie, the AI assistant for AgenticAdvertising.org. Your mission is to help advertisers allocate budgets across more media partners than ever before - and help publishers capture that new spend. + +**The Core Thesis:** Today, advertisers work with only 3-5 platforms because execution costs are brutal. The opportunity isn't optimizing those platforms better - it's expanding to 20+ partners without tripling your team. AI agents collapse this complexity cost. This is a $1-2 trillion allocation problem, not an efficiency problem. + +**The Analogy:** "OpenRTB is a protocol for day trading; AdCP is a protocol for investing." RTB reasons about impressions in real-time. AdCP reasons about budgets, relationships, and outcomes over time. ## PRIORITY: Account Setup diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts index 8e99ae3332..5d4adb2e1e 100644 --- a/server/src/addie/services/content-curator.ts +++ b/server/src/addie/services/content-curator.ts @@ -201,7 +201,8 @@ Provide your analysis as JSON with this structure: - Ends with a question or "What's your take?" to invite discussion - Is 1-2 sentences max, punchy and clickbaity - Examples: - - "🤖 Big Tech is building AI agents that lock you into their walled gardens. Is open-source AdCP the antidote, or already too late? What's your take?" + - "🏰 Walled gardens avoid RTB because it commoditizes their differentiation. AdCP lets them capture new allocation budgets without sacrificing control. Win-win?" + - "📊 Advertisers work with 3-5 platforms today because execution costs are brutal. Agents could unlock 20+ partners. Who captures that spend?" - "💰 Another day, another ad tech acquisition. Consolidation keeps winners winning. How do independents compete?" - "⚖️ This antitrust ruling could reshape how measurement monopolies operate. Good news for open standards?" diff --git a/server/src/db/migrations/157_allocation_vs_efficiency_rule.sql b/server/src/db/migrations/157_allocation_vs_efficiency_rule.sql new file mode 100644 index 0000000000..c3be93f947 --- /dev/null +++ b/server/src/db/migrations/157_allocation_vs_efficiency_rule.sql @@ -0,0 +1,32 @@ +-- Addie Operating Rule: Allocation vs Efficiency +-- Key insight from BOK on Ads article about why AdCP benefits walled gardens + +INSERT INTO addie_rules (rule_type, name, description, content, priority, created_by) VALUES +( + 'knowledge', + 'Allocation vs Efficiency', + 'Core thesis: AdCP enables allocation (expanding media partners) not just efficiency', + 'KEY INSIGHT: Agentic advertising is about ALLOCATION, not just efficiency. + +**The Day Trading vs Investing Analogy:** +- OpenRTB is a protocol for day trading: reasoning about individual impressions in real-time +- AdCP is a protocol for investing: reasoning about budgets, relationships, and outcomes over time + +**The Real Constraint Today:** +Advertisers effectively work with only 3-5 platforms due to execution costs. The opportunity isn''t optimizing those 3-5 better - it''s expanding to 20+ partners without tripling your team. This is a $1-2 trillion allocation problem. + +**Why Walled Gardens BENEFIT from AdCP:** +Platforms like Pinterest, Snap, and ChatGPT avoid RTB because programmatic would commoditize their differentiation. AdCP lets them: +- Leverage proprietary data and closed-loop measurement +- Maintain control over their inventory and pricing +- Capture new allocation budgets from advertisers expanding beyond Google/Meta +- Optimize toward outcomes rather than impressions + +**Correct Framing:** +- WRONG: "AdCP threatens walled gardens by forcing them open" +- RIGHT: "AdCP gives walled gardens a way to capture new budgets while preserving their advantages" + +Walled gardens should be excited about AdCP, not threatened. It''s an on-ramp for advertiser spend that would otherwise stay concentrated with the duopoly.', + 172, + 'system' +); diff --git a/server/src/db/migrations/158_update_core_mission_allocation_thesis.sql b/server/src/db/migrations/158_update_core_mission_allocation_thesis.sql new file mode 100644 index 0000000000..6fc400eb0d --- /dev/null +++ b/server/src/db/migrations/158_update_core_mission_allocation_thesis.sql @@ -0,0 +1,60 @@ +-- Update Core Mission to incorporate the allocation thesis +-- Key insight: AdCP is about allocation (more partners) not just efficiency (better optimization) +-- +-- DEPENDS ON: migration 053_addie_operating_rules.sql which creates the 'Core Mission' and +-- 'Programmatic and OpenRTB' rules. If those rules don't exist, these UPDATEs will silently do nothing. + +-- Update the existing Core Mission rule with the strategic framing +UPDATE addie_rules +SET content = 'You are Addie, the AI assistant for AgenticAdvertising.org. Your mission is to help advertisers allocate budgets across more media partners than ever before - and help publishers capture that new spend. + +**The Core Thesis:** +Today, advertisers effectively work with only 3-5 platforms because execution costs are brutal. The opportunity isn''t optimizing those platforms better - it''s expanding to 20+ partners without tripling your team. AI agents collapse this complexity cost. + +This is a $1-2 trillion allocation problem, not an efficiency problem. + +**Why This Matters:** +- For advertisers: Scale from 3 partners to 20 without adding headcount +- For publishers/platforms: Capture budgets that would otherwise stay concentrated with the duopoly +- For walled gardens: Preserve your differentiation while accessing new allocation budgets (RTB would commoditize you; AdCP doesn''t) + +**The Analogy:** +"OpenRTB is a protocol for day trading; AdCP is a protocol for investing." RTB reasons about individual impressions in real-time. AdCP reasons about budgets, relationships, and outcomes over time. + +AgenticAdvertising.org is the membership organization and community. AdCP (Ad Context Protocol) is the technical protocol specification. Members join AgenticAdvertising.org to participate in developing and adopting AdCP - shaping a protocol that will impact trillions of dollars of global commerce.', + description = 'Addie''s core mission and the strategic thesis for AdCP', + updated_at = NOW() +WHERE name = 'Core Mission' + AND rule_type = 'system_prompt' + AND created_by = 'system'; + +-- Also update the Programmatic and OpenRTB rule to align with the allocation framing +UPDATE addie_rules +SET content = 'Know how programmatic advertising works, including OpenRTB and Prebid: +- Real-time bidding mechanics and auction dynamics +- Header bidding and prebid.js +- Supply-side and demand-side platforms +- Data management platforms and audience targeting +- Ad exchanges and private marketplaces + +**Historical Context:** +Programmatic/RTB was created to solve remnant impression mediation - helping publishers figure out which ad network would pay most for unsold inventory. It optimized for the question "what is this impression worth?" + +**Why AdCP is Different:** +AdCP solves a different problem: allocation. It answers "how much should I deploy and where?" This is why: +- Walled gardens avoided RTB (it would commoditize their differentiation) +- MFA sites thrived in RTB (they optimized for what RTB could measure) +- Advertisers are stuck with 3-5 partners (execution costs prevent scaling) + +**What AdCP Can Reason About That RTB Cannot:** +- Narrative horizon (time, repetition, shelf-life of campaigns) +- Institutional trust (environment context, brand safety) +- Relationship provenance (vs surveillance-based targeting) +- Multi-month outcome feedback (vs impression-level optimization) + +AdCP doesn''t replace RTB for remnant mediation. It enables a different kind of advertising relationship - one based on outcomes and trust rather than auction dynamics.', + description = 'Deep knowledge of programmatic and how AdCP differs strategically', + updated_at = NOW() +WHERE name = 'Programmatic and OpenRTB' + AND rule_type = 'knowledge' + AND created_by = 'system'; From 000cb395f68f8265ba2e4dc5260da0cd33d87f9d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 15:27:21 -0500 Subject: [PATCH 37/77] docs: clarify AdCP/MCP relationship - MCP is transport, not base (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates documentation to clarify that AdCP works *over* MCP and A2A as transports rather than being "built on" MCP. This addresses confusion where people thought AdCP was a fork or outdated branch of the MCP protocol. The actual relationship: - MCP and A2A are transports (like HTTP) - AdCP defines domain-specific tasks and schemas for advertising - AdCP uses the latest MCP SDK (1.25.2) as a dependency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/orange-bottles-reply.md | 4 ++++ README.md | 4 ++-- docs/intro.mdx | 6 +++--- docs/reference/glossary.mdx | 4 ++-- server/public/index.html | 8 ++++---- 5 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 .changeset/orange-bottles-reply.md 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/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/docs/intro.mdx b/docs/intro.mdx index 7ec71cf192..9e7d063f03 100644 --- a/docs/intro.mdx +++ b/docs/intro.mdx @@ -1,7 +1,7 @@ --- sidebar_position: 1 title: Getting Started -description: AdCP is an open standard for advertising automation built on Model Context Protocol (MCP). Learn how to integrate AI-powered advertising workflows. +description: AdCP is an open standard for advertising automation that works over MCP and A2A protocols. Learn how to integrate AI-powered advertising workflows. keywords: [advertising automation protocol, programmatic advertising API, MCP advertising integration, AI advertising workflows, unified advertising platform API] --- @@ -19,8 +19,8 @@ Welcome to the Ad Context Protocol (AdCP) documentation. AdCP is an **open stand Ad Context Protocol (AdCP) is an **open standard for advertising automation** that enables AI-powered programmatic advertising workflows through: - **Unified Advertising API**: Single interface for all advertising platforms -- **AI-Powered Automation**: Built on Model Context Protocol (MCP) for seamless AI integration -- **Multi-Protocol Support**: Access through MCP, A2A, or future protocols +- **AI-Powered Automation**: Works over MCP and A2A protocols for seamless AI integration +- **Multi-Protocol Support**: Access through MCP, A2A, or future transports - **Platform Agnostic**: Works with any compatible advertising platform - **Programmatic Advertising Made Simple**: Standardized workflows across all ad tech diff --git a/docs/reference/glossary.mdx b/docs/reference/glossary.mdx index 81859db6ad..e069d90d63 100644 --- a/docs/reference/glossary.mdx +++ b/docs/reference/glossary.mdx @@ -12,8 +12,8 @@ Classification of MCP session credentials as either "platform" (aggregator) or " **Activation** The process of making a signal available for targeting on a specific platform and seat. -**Ad Context Protocol (AdCP)** -An open standard based on Model Context Protocol (MCP) that enables AI-powered advertising workflows through natural language interfaces. +**Ad Context Protocol (AdCP)** +An open standard for AI-powered advertising workflows. AdCP defines domain-specific tasks and schemas that work over MCP and A2A as transports, enabling natural language interfaces for advertising operations. **Agentic eXecution Engine (AXE)** The real-time execution layer that sits between orchestrators and decisioning platforms, handling dynamic audience targeting, brand safety enforcement, frequency management, and first-party data activation at impression time. See [AXE documentation](/docs/media-buy/advanced-topics/agentic-execution-engine) for details. diff --git a/server/public/index.html b/server/public/index.html index b4efab6f1e..1662374490 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -31,7 +31,7 @@ "@id": "https://adcontextprotocol.org/#software", "name": "Ad Context Protocol", "alternateName": "AdCP", - "description": "Unified advertising automation protocol built on Model Context Protocol (MCP). Enables AI-powered workflows across advertising platforms including media buying, creative generation, and first-party data signals.", + "description": "Open standard for advertising automation that works over MCP and A2A protocols. Enables AI-powered workflows across advertising platforms including media buying, creative generation, and first-party data signals.", "applicationCategory": "DeveloperApplication", "operatingSystem": "Any", "offers": { @@ -85,7 +85,7 @@ "name": "What protocols does AdCP support?", "acceptedAnswer": { "@type": "Answer", - "text": "AdCP is built on the Model Context Protocol (MCP) and supports Agent-to-Agent (A2A) protocol. This enables seamless integration with AI assistants like Claude, ChatGPT, and custom AI agents." + "text": "AdCP works over MCP (Model Context Protocol) and A2A (Agent-to-Agent) as transports. This enables seamless integration with AI assistants like Claude, ChatGPT, and custom AI agents." } }, { @@ -108,7 +108,7 @@ } ] } -</script><meta name="description" content="AdCP (Ad Context Protocol) is an open standard that unifies advertising platforms through AI-powered workflows. Built on MCP for seamless programmatic advertising automation."> +</script><meta name="description" content="AdCP (Ad Context Protocol) is an open standard that unifies advertising platforms through AI-powered workflows. Works over MCP and A2A for seamless programmatic advertising automation."> <meta name="keywords" content="advertising automation protocol, programmatic advertising API, MCP advertising integration, AI advertising workflows, unified advertising platform API, advertising technology, agent-to-agent, A2A protocol, AI agents, agentic advertising"> <meta name="author" content="Ad Context Protocol Contributors"> <meta name="publisher" content="Ad Context Protocol"> @@ -577,7 +577,7 @@ <h3>⚡ Protocol Features</h3> <div class="row"> <div class="col col--10 col--offset-1"> <h2 class="sectionTitle_Ut5p">How AdCP works</h2> - <p class="sectionSubtitle_AZuW">Built on the Model Context Protocol (MCP), AdCP provides a unified interface for advertising operations across any platform.</p> + <p class="sectionSubtitle_AZuW">AdCP works over MCP and A2A protocols, providing a unified interface for advertising operations across any platform.</p> <div class="workflowSteps_gwt9"> <div class="step_BHwS"> <div class="stepNumber_rxuX">1</div> From 544a29e585bdaed5c194f269f61058a82e9c3ea7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 16:25:04 -0500 Subject: [PATCH 38/77] feat: add committee document tracking with AI summaries (#714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add committee document tracking with AI summaries Enables working groups to track external documents (Google Docs) with: - Automatic content indexing and change detection via SHA-256 hashing - AI-powered document summaries and change descriptions - Scheduled jobs for hourly document indexing and daily activity summaries - Frontend UI for leaders to manage tracked documents - Public display of documents with summaries on working group pages Security improvements: - Rate limiting on manual reindex endpoint (5 req/min per user) - Strict URL validation (whitelist Google Docs domains only) - UUID validation on all document ID parameters - Empty content edge case handling Database: New tables committee_documents, committee_summaries, committee_document_activity with composite index for efficient queries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract job scheduling to dedicated scheduler module Moves committee document indexer and summary generator job scheduling from http.ts to a centralized scheduler.ts module. This: - Reduces http.ts file size by ~70 lines - Creates a single place to manage scheduled jobs - Makes it easier to add new jobs in the future - Improves code organization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/loud-pigs-heal.md | 2 + server/public/working-groups/detail.html | 224 +++++++++ server/public/working-groups/manage.html | 425 +++++++++++++++++- .../addie/jobs/committee-document-indexer.ts | 367 +++++++++++++++ .../addie/jobs/committee-summary-generator.ts | 286 ++++++++++++ server/src/addie/jobs/scheduler.ts | 159 +++++++ .../db/migrations/159_committee_documents.sql | 132 ++++++ server/src/db/working-group-db.ts | 352 +++++++++++++++ server/src/http.ts | 8 + server/src/routes/committees.ts | 369 +++++++++++++++ server/src/types.ts | 77 ++++ 11 files changed, 2397 insertions(+), 4 deletions(-) create mode 100644 .changeset/loud-pigs-heal.md create mode 100644 server/src/addie/jobs/committee-document-indexer.ts create mode 100644 server/src/addie/jobs/committee-summary-generator.ts create mode 100644 server/src/addie/jobs/scheduler.ts create mode 100644 server/src/db/migrations/159_committee_documents.sql 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/server/public/working-groups/detail.html b/server/public/working-groups/detail.html index 0e0e6eba56..c9df4ab91e 100644 --- a/server/public/working-groups/detail.html +++ b/server/public/working-groups/detail.html @@ -532,6 +532,120 @@ margin-top: var(--space-2); } + /* Documents Section */ + .documents-list { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + .document-card { + display: flex; + gap: var(--space-4); + padding: var(--space-4); + background: var(--color-gray-50); + border-radius: var(--radius-lg); + border: var(--border-1) solid var(--color-border); + transition: var(--transition-all); + } + + .document-card:hover { + border-color: var(--color-brand); + background: var(--color-primary-50); + } + + .document-card.featured { + border-left: 4px solid var(--color-brand); + } + + .document-icon { + flex-shrink: 0; + width: 48px; + height: 48px; + background: var(--color-primary-100); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-brand); + } + + .document-icon svg { + width: 24px; + height: 24px; + } + + .document-info { + flex: 1; + min-width: 0; + } + + .document-title { + font-size: var(--text-base); + font-weight: var(--font-semibold); + color: var(--color-text-heading); + margin-bottom: var(--space-1); + display: flex; + align-items: center; + gap: var(--space-2); + } + + .document-title a { + color: var(--color-text-heading); + text-decoration: none; + } + + .document-title a:hover { + color: var(--color-brand); + } + + .document-meta { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-2); + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + } + + .document-summary { + font-size: var(--text-sm); + color: var(--color-text-body); + line-height: var(--leading-relaxed); + } + + .document-status { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-medium); + } + + .document-status.success { + background: var(--color-success-50); + color: var(--color-success-700); + } + + .document-status.pending { + background: var(--color-warning-50); + color: var(--color-warning-700); + } + + .document-status.error { + background: var(--color-error-50); + color: var(--color-error-600); + } + + .empty-documents { + text-align: center; + padding: var(--space-8); + color: var(--color-text-muted); + } + .badge-members-only { display: inline-block; padding: 2px var(--space-2); @@ -813,6 +927,14 @@ <h2 class="section-title">About This Group</h2> <div id="descriptionContent" class="description-content"></div> </div> + <!-- Documents Section --> + <div id="documentsSection" class="content-section" style="display: none;"> + <div class="section-header"> + <h2 class="section-title">Documents</h2> + </div> + <div id="documentsList" class="documents-list"></div> + </div> + <!-- Events Section (chapters only) --> <div id="eventsSection" class="content-section" style="display: none;"> <div class="section-header"> @@ -1013,6 +1135,7 @@ <h2 id="modalTitle">New Post</h2> renderWorkingGroup(); loadPosts(); + loadDocuments(); // Load events for chapters if (currentGroup.committee_type === 'chapter') { @@ -1271,6 +1394,107 @@ <h3 class="events-subsection-title">Recent Events</h3> } } + async function loadDocuments() { + try { + const response = await fetch(`/api/working-groups/${currentSlug}/documents`); + if (!response.ok) return; + + const data = await response.json(); + const documents = data.documents || []; + + const documentsSection = document.getElementById('documentsSection'); + const documentsList = document.getElementById('documentsList'); + + // Only show the section if there are documents + if (documents.length === 0) { + return; + } + + documentsSection.style.display = 'block'; + + documentsList.innerHTML = documents.map(doc => { + const icon = getDocumentIcon(doc.document_type); + const statusClass = doc.index_status === 'success' ? 'success' : + doc.index_status === 'pending' ? 'pending' : 'error'; + const lastModified = doc.last_modified_at ? formatRelativeDate(doc.last_modified_at) : null; + + return ` + <div class="document-card${doc.is_featured ? ' featured' : ''}"> + <div class="document-icon"> + ${icon} + </div> + <div class="document-info"> + <div class="document-title"> + <a href="${escapeHtml(doc.document_url)}" target="_blank" rel="noopener"> + ${escapeHtml(doc.title)} + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="opacity: 0.5;"> + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> + <polyline points="15 3 21 3 21 9"/> + <line x1="10" y1="14" x2="21" y2="3"/> + </svg> + </a> + </div> + <div class="document-meta"> + ${lastModified ? `<span>Updated ${lastModified}</span>` : ''} + ${doc.index_status !== 'success' ? `<span class="document-status ${statusClass}">${doc.index_status}</span>` : ''} + </div> + ${doc.document_summary ? `<div class="document-summary">${escapeHtml(doc.document_summary)}</div>` : ''} + </div> + </div> + `; + }).join(''); + + } catch (error) { + console.error('Error loading documents:', error); + } + } + + function getDocumentIcon(docType) { + switch (docType) { + case 'google_doc': + return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> + <polyline points="14 2 14 8 20 8"/> + <line x1="16" y1="13" x2="8" y2="13"/> + <line x1="16" y1="17" x2="8" y2="17"/> + <line x1="10" y1="9" x2="8" y2="9"/> + </svg>`; + case 'google_sheet': + return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/> + <line x1="3" y1="9" x2="21" y2="9"/> + <line x1="3" y1="15" x2="21" y2="15"/> + <line x1="9" y1="3" x2="9" y2="21"/> + <line x1="15" y1="3" x2="15" y2="21"/> + </svg>`; + case 'pdf': + return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> + <polyline points="14 2 14 8 20 8"/> + </svg>`; + default: + return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> + </svg>`; + } + } + + function formatRelativeDate(dateStr) { + if (!dateStr) return ''; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'today'; + if (diffDays === 1) return 'yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`; + return `${Math.floor(diffDays / 365)} years ago`; + } + function renderEventCard(event, isPast) { const eventDate = new Date(event.start_time); const eventTimezone = event.timezone || 'America/New_York'; diff --git a/server/public/working-groups/manage.html b/server/public/working-groups/manage.html index 4d2363b8a4..69a49f9459 100644 --- a/server/public/working-groups/manage.html +++ b/server/public/working-groups/manage.html @@ -351,6 +351,78 @@ .badge-hybrid { background: var(--color-purple-100, var(--color-primary-100)); color: var(--color-purple-700, var(--color-primary-700)); } .badge-cancelled { background: var(--color-error-100); color: var(--color-error-700); } + /* Documents list */ + .documents-list { + display: grid; + gap: var(--space-3); + } + .document-item { + border: var(--border-1) solid var(--color-gray-200); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + } + .document-item:hover { + border-color: var(--color-brand); + } + .document-item.featured { + border-left: 3px solid var(--color-brand); + } + .document-info h3 { + font-size: var(--text-base); + margin-bottom: var(--space-1); + color: var(--color-text-heading); + display: flex; + align-items: center; + gap: var(--space-2); + } + .document-info h3 a { + color: inherit; + text-decoration: none; + } + .document-info h3 a:hover { + color: var(--color-brand); + } + .document-info h3 svg { + opacity: 0.5; + flex-shrink: 0; + } + .document-meta { + font-size: var(--text-sm); + color: var(--color-text-secondary); + display: flex; + gap: var(--space-2.5); + flex-wrap: wrap; + align-items: center; + margin-bottom: var(--space-2); + } + .document-summary-preview { + font-size: var(--text-sm); + color: var(--color-text-body); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .document-actions { + display: flex; + gap: var(--space-2); + flex-shrink: 0; + } + .badge-google_doc { background: var(--color-info-100); color: var(--color-info-700); } + .badge-google_sheet { background: var(--color-success-100); color: var(--color-success-700); } + .badge-external_link { background: var(--color-primary-100); color: var(--color-primary-700); } + .badge-pdf { background: var(--color-error-100); color: var(--color-error-700); } + .badge-other { background: var(--color-gray-200); color: var(--color-gray-700); } + .badge-pending { background: var(--color-warning-100); color: var(--color-warning-700); } + .badge-success { background: var(--color-success-100); color: var(--color-success-700); } + .badge-error { background: var(--color-error-100); color: var(--color-error-700); } + .badge-access_denied { background: var(--color-error-100); color: var(--color-error-700); } + .badge-featured { background: var(--color-brand); color: white; } + @media (max-width: 768px) { .form-row { grid-template-columns: 1fr; } .editor-preview { grid-template-columns: 1fr; } @@ -413,6 +485,13 @@ <h1 id="pageHeading">Manage</h1> </svg> Events </button> + <button class="tab-btn" onclick="switchTab('documents')" id="documentsTab"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> + <polyline points="14 2 14 8 20 8"/> + </svg> + Documents + </button> </div> <!-- Posts Tab Content --> @@ -448,6 +527,27 @@ <h1 id="pageHeading">Manage</h1> Loading events... </div> </div> + + <!-- Documents Tab Content --> + <div id="documentsContent" class="tab-content"> + <div class="header-bar" style="margin-bottom: var(--space-4);"> + <div> + <p style="font-size: var(--text-sm); color: var(--color-text-muted);"> + Track external documents like Google Docs. Documents are automatically indexed and summarized. + </p> + </div> + <button class="btn btn-primary" onclick="showCreateDocumentModal()"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <line x1="12" y1="5" x2="12" y2="19"/> + <line x1="5" y1="12" x2="19" y2="12"/> + </svg> + Add Document + </button> + </div> + <div id="documentsList" class="documents-list"> + Loading documents... + </div> + </div> </div> </div> @@ -731,15 +831,90 @@ <h2>Delete Event</h2> </div> </div> + <!-- Document Create/Edit Modal --> + <div id="documentModal" class="modal-overlay"> + <div class="modal" style="max-width: 600px;"> + <h2 id="documentModalTitle">Add Document</h2> + + <form id="documentForm" onsubmit="saveDocument(event)"> + <input type="hidden" id="documentId"> + + <div class="form-group"> + <label>Document Title *</label> + <input type="text" id="documentTitle" required placeholder="e.g., Governance Framework Draft"> + </div> + + <div class="form-group"> + <label>Document URL *</label> + <input type="url" id="documentUrl" required placeholder="https://docs.google.com/document/d/..."> + <small>Google Docs URLs are automatically indexed and summarized</small> + </div> + + <div class="form-group"> + <label>Description</label> + <textarea id="documentDescription" placeholder="Brief description of what this document is for..." rows="3"></textarea> + </div> + + <div class="form-row"> + <div class="form-group"> + <label>Document Type</label> + <select id="documentType"> + <option value="google_doc">Google Doc</option> + <option value="google_sheet">Google Sheet</option> + <option value="external_link">External Link</option> + <option value="pdf">PDF</option> + <option value="other">Other</option> + </select> + </div> + <div class="form-group"> + <label>Display Order</label> + <input type="number" id="documentDisplayOrder" value="0" min="0" placeholder="0"> + <small>Lower numbers appear first</small> + </div> + </div> + + <div class="form-group"> + <label style="display: flex; align-items: center; gap: var(--space-2); cursor: pointer;"> + <input type="checkbox" id="documentIsFeatured" style="width: auto;"> + <span>Featured Document</span> + </label> + <small style="color: var(--color-text-muted);">Featured documents are highlighted on the working group page</small> + </div> + + <div class="modal-buttons"> + <button type="button" class="btn btn-secondary" onclick="closeDocumentModal()">Cancel</button> + <button type="submit" class="btn btn-primary" id="saveDocumentBtn">Save Document</button> + </div> + </form> + </div> + </div> + + <!-- Delete Document Confirmation Modal --> + <div id="deleteDocumentModal" class="modal-overlay"> + <div class="modal" style="max-width: 450px;"> + <h2>Delete Document</h2> + <p style="margin: var(--space-5) 0; color: var(--color-text-secondary);"> + Are you sure you want to delete "<span id="deleteDocumentTitle"></span>"? This action cannot be undone. + </p> + <div class="modal-buttons"> + <button type="button" class="btn btn-secondary" onclick="closeDeleteDocumentModal()">Cancel</button> + <button type="button" class="btn btn-danger" onclick="confirmDeleteDocument()">Delete</button> + </div> + </div> + </div> + <script> let currentSlug = ''; let currentGroup = null; let posts = []; let events = []; + let documents = []; let editingPost = null; let editingEvent = null; + let editingDocument = null; let deletingPostId = null; let deletingEventId = null; + let deletingDocumentId = null; let currentTab = 'posts'; async function init() { @@ -758,8 +933,8 @@ <h2>Delete Event</h2> // Check for tab query param const urlParams = new URLSearchParams(window.location.search); const tabParam = urlParams.get('tab'); - if (tabParam === 'events') { - currentTab = 'events'; + if (tabParam === 'events' || tabParam === 'documents') { + currentTab = tabParam; } // Try to load posts - this will fail with 403 if not a leader @@ -782,10 +957,15 @@ <h2>Delete Event</h2> loadEvents(); } + // Load documents if switching to documents tab for first time + if (tab === 'documents' && documents.length === 0) { + loadDocuments(); + } + // Update URL without reload const url = new URL(window.location); - if (tab === 'events') { - url.searchParams.set('tab', 'events'); + if (tab === 'events' || tab === 'documents') { + url.searchParams.set('tab', tab); } else { url.searchParams.delete('tab'); } @@ -1414,6 +1594,243 @@ <h3>${escapeHtml(event.title)}</h3> } } + // ========================================================================= + // DOCUMENT MANAGEMENT FUNCTIONS + // ========================================================================= + + async function loadDocuments() { + try { + const response = await fetch(`/api/working-groups/${currentSlug}/documents`, { + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Failed to load documents'); + } + + const data = await response.json(); + documents = data.documents || []; + renderDocuments(); + } catch (error) { + console.error('Error loading documents:', error); + document.getElementById('documentsList').innerHTML = ` + <div class="empty-state"> + <p>Failed to load documents. Please try again.</p> + </div> + `; + } + } + + function renderDocuments() { + const listDiv = document.getElementById('documentsList'); + + if (documents.length === 0) { + listDiv.innerHTML = ` + <div class="empty-state"> + <p>No documents tracked yet.</p> + <p>Click "Add Document" to start tracking external documents.</p> + </div> + `; + return; + } + + listDiv.innerHTML = documents.map(doc => { + const typeBadge = `<span class="badge badge-${doc.document_type}">${doc.document_type.replace('_', ' ')}</span>`; + const statusBadge = doc.index_status !== 'success' + ? `<span class="badge badge-${doc.index_status}">${doc.index_status}</span>` + : ''; + const featuredBadge = doc.is_featured ? `<span class="badge badge-featured">featured</span>` : ''; + const lastModified = doc.last_modified_at + ? formatRelativeDate(doc.last_modified_at) + : ''; + + return ` + <div class="document-item${doc.is_featured ? ' featured' : ''}"> + <div class="document-info" style="flex: 1;"> + <h3> + <a href="${escapeHtml(doc.document_url)}" target="_blank" rel="noopener"> + ${escapeHtml(doc.title)} + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> + <polyline points="15 3 21 3 21 9"/> + <line x1="10" y1="14" x2="21" y2="3"/> + </svg> + </a> + </h3> + <div class="document-meta"> + ${typeBadge} + ${statusBadge} + ${featuredBadge} + ${lastModified ? `<span>Updated ${lastModified}</span>` : ''} + </div> + ${doc.document_summary ? `<div class="document-summary-preview">${escapeHtml(doc.document_summary)}</div>` : ''} + </div> + <div class="document-actions"> + <button class="btn btn-secondary btn-small" onclick="reindexDocument('${doc.id}')" title="Refresh content"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M23 4v6h-6"/> + <path d="M1 20v-6h6"/> + <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/> + </svg> + </button> + <button class="btn btn-primary btn-small" onclick="editDocument('${doc.id}')">Edit</button> + <button class="btn btn-danger btn-small" onclick="showDeleteDocumentModal('${doc.id}', '${escapeHtml(doc.title).replace(/'/g, "\\'")}')">Delete</button> + </div> + </div> + `; + }).join(''); + } + + function formatRelativeDate(dateStr) { + if (!dateStr) return ''; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'today'; + if (diffDays === 1) return 'yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`; + return `${Math.floor(diffDays / 365)} years ago`; + } + + function showCreateDocumentModal() { + editingDocument = null; + document.getElementById('documentModalTitle').textContent = 'Add Document'; + document.getElementById('documentForm').reset(); + document.getElementById('documentId').value = ''; + document.getElementById('documentDisplayOrder').value = '0'; + document.getElementById('documentModal').style.display = 'flex'; + } + + async function editDocument(id) { + const doc = documents.find(d => d.id === id); + if (!doc) return; + + editingDocument = doc; + + document.getElementById('documentModalTitle').textContent = 'Edit Document'; + document.getElementById('documentId').value = doc.id; + document.getElementById('documentTitle').value = doc.title || ''; + document.getElementById('documentUrl').value = doc.document_url || ''; + document.getElementById('documentDescription').value = doc.description || ''; + document.getElementById('documentType').value = doc.document_type || 'google_doc'; + document.getElementById('documentDisplayOrder').value = doc.display_order || 0; + document.getElementById('documentIsFeatured').checked = doc.is_featured || false; + + document.getElementById('documentModal').style.display = 'flex'; + } + + function closeDocumentModal() { + document.getElementById('documentModal').style.display = 'none'; + editingDocument = null; + } + + async function saveDocument(e) { + e.preventDefault(); + + const id = document.getElementById('documentId').value; + const data = { + title: document.getElementById('documentTitle').value.trim(), + document_url: document.getElementById('documentUrl').value.trim(), + description: document.getElementById('documentDescription').value.trim() || null, + document_type: document.getElementById('documentType').value, + display_order: parseInt(document.getElementById('documentDisplayOrder').value) || 0, + is_featured: document.getElementById('documentIsFeatured').checked, + }; + + const saveBtn = document.getElementById('saveDocumentBtn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const url = id + ? `/api/working-groups/${currentSlug}/documents/${id}` + : `/api/working-groups/${currentSlug}/documents`; + const method = id ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to save'); + } + + closeDocumentModal(); + await loadDocuments(); + } catch (error) { + console.error('Error saving:', error); + alert('Failed to save: ' + error.message); + } + + saveBtn.disabled = false; + saveBtn.textContent = 'Save Document'; + } + + async function reindexDocument(id) { + const doc = documents.find(d => d.id === id); + if (!doc) return; + + if (!confirm(`Refresh content for "${doc.title}"? This will re-fetch and re-summarize the document.`)) { + return; + } + + try { + const response = await fetch(`/api/working-groups/${currentSlug}/documents/${id}/reindex`, { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to reindex'); + } + + alert('Document content refreshed successfully!'); + await loadDocuments(); + } catch (error) { + console.error('Error reindexing:', error); + alert('Failed to refresh: ' + error.message); + } + } + + function showDeleteDocumentModal(id, title) { + deletingDocumentId = id; + document.getElementById('deleteDocumentTitle').textContent = title; + document.getElementById('deleteDocumentModal').style.display = 'flex'; + } + + function closeDeleteDocumentModal() { + document.getElementById('deleteDocumentModal').style.display = 'none'; + deletingDocumentId = null; + } + + async function confirmDeleteDocument() { + if (!deletingDocumentId) return; + + try { + const response = await fetch(`/api/working-groups/${currentSlug}/documents/${deletingDocumentId}`, { + method: 'DELETE', + credentials: 'include' + }); + + if (!response.ok) throw new Error('Failed to delete'); + + closeDeleteDocumentModal(); + await loadDocuments(); + } catch (error) { + console.error('Error deleting:', error); + alert('Failed to delete: ' + error.message); + } + } + // Event listeners document.getElementById('excerpt').addEventListener('input', updateExcerptCount); document.getElementById('title').addEventListener('input', function() { diff --git a/server/src/addie/jobs/committee-document-indexer.ts b/server/src/addie/jobs/committee-document-indexer.ts new file mode 100644 index 0000000000..e6d416ade6 --- /dev/null +++ b/server/src/addie/jobs/committee-document-indexer.ts @@ -0,0 +1,367 @@ +/** + * Committee Document Indexer Job + * + * Periodically indexes Google Docs and other documents tracked by committees. + * Detects content changes and generates AI summaries. + * + * Process: + * 1. Find documents that need indexing (new or stale) + * 2. Fetch content from Google Docs API + * 3. Compute content hash to detect changes + * 4. Log activity if content changed + * 5. Generate/update document summary if needed + */ + +import * as crypto from 'crypto'; +import Anthropic from '@anthropic-ai/sdk'; +import { logger } from '../../logger.js'; +import { WorkingGroupDatabase } from '../../db/working-group-db.js'; +import { + isGoogleDocsUrl, + createGoogleDocsToolHandlers, + GOOGLE_DOCS_ERROR_PREFIX, + GOOGLE_DOCS_ACCESS_DENIED_PREFIX, +} from '../mcp/google-docs.js'; +import type { CommitteeDocument, DocumentIndexStatus } from '../../types.js'; + +const workingGroupDb = new WorkingGroupDatabase(); + +// Use same model as main Addie assistant +const SUMMARIZER_MODEL = process.env.ADDIE_MODEL || 'claude-sonnet-4-20250514'; + +export interface DocumentIndexResult { + documentsChecked: number; + documentsChanged: number; + documentsError: number; + summariesGenerated: number; +} + +/** + * Compute SHA-256 hash of content for change detection + */ +function computeContentHash(content: string): string { + return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); +} + +/** + * Fetch content from a Google Doc + */ +async function fetchGoogleDocContent(url: string): Promise<{ + content: string; + error?: string; + status: DocumentIndexStatus; +}> { + const handlers = createGoogleDocsToolHandlers(); + + if (!handlers) { + return { + content: '', + error: 'Google Docs API not configured', + status: 'error', + }; + } + + try { + const result = await handlers.read_google_doc({ url }); + + // Check for access denied + if (result.startsWith(GOOGLE_DOCS_ACCESS_DENIED_PREFIX)) { + return { + content: '', + error: result, + status: 'access_denied', + }; + } + + // Check for other errors + if (result.startsWith(GOOGLE_DOCS_ERROR_PREFIX)) { + return { + content: '', + error: result, + status: 'error', + }; + } + + // Strip the title/format header if present + const contentMatch = result.match(/^\*\*[^*]+\*\*[^\n]*\n\n([\s\S]*)$/); + const content = contentMatch ? contentMatch[1] : result; + + return { + content, + status: 'success', + }; + } catch (error) { + return { + content: '', + error: error instanceof Error ? error.message : 'Unknown error', + status: 'error', + }; + } +} + +/** + * Generate a summary of document content using Claude + */ +async function generateDocumentSummary( + title: string, + content: string, + committeeContext?: string +): Promise<string> { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY not configured'); + } + + // Truncate content to avoid token limits + const maxContentLength = 30000; + const truncatedContent = content.length > maxContentLength + ? content.substring(0, maxContentLength) + '\n\n[Content truncated...]' + : content; + + const client = new Anthropic({ apiKey }); + + const systemPrompt = `You are summarizing a document for a working group at AgenticAdvertising.org. +Generate a brief, informative summary (2-4 sentences) that captures the key points. +Focus on what the document covers and any important updates or decisions. +Be concise and factual.`; + + const userPrompt = `Document: "${title}" +${committeeContext ? `Committee context: ${committeeContext}\n` : ''} +Content: +${truncatedContent} + +Write a brief summary (2-4 sentences) of this document.`; + + const response = await client.messages.create({ + model: SUMMARIZER_MODEL, + max_tokens: 300, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }); + + const textContent = response.content.find(block => block.type === 'text'); + return textContent?.text || 'Unable to generate summary.'; +} + +/** + * Generate a summary of what changed between document versions + */ +async function generateChangeSummary( + title: string, + oldContent: string, + newContent: string +): Promise<string> { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + return 'Document content was updated.'; + } + + // Truncate content + const maxLength = 15000; + const truncatedOld = oldContent.length > maxLength + ? oldContent.substring(0, maxLength) + '...' + : oldContent; + const truncatedNew = newContent.length > maxLength + ? newContent.substring(0, maxLength) + '...' + : newContent; + + const client = new Anthropic({ apiKey }); + + const response = await client.messages.create({ + model: SUMMARIZER_MODEL, + max_tokens: 200, + system: 'Summarize the key changes between the old and new versions of this document in 1-2 sentences. Focus on substantive changes, not formatting.', + messages: [{ + role: 'user', + content: `Document: "${title}"\n\nOLD VERSION:\n${truncatedOld}\n\nNEW VERSION:\n${truncatedNew}\n\nWhat changed?`, + }], + }); + + const textContent = response.content.find(block => block.type === 'text'); + return textContent?.text || 'Document was updated.'; +} + +/** + * Index a single document + */ +async function indexDocument(doc: CommitteeDocument): Promise<{ + changed: boolean; + error?: string; + summary?: string; +}> { + // Only Google Docs are supported for now + if (!isGoogleDocsUrl(doc.document_url)) { + logger.debug({ documentId: doc.id, url: doc.document_url }, 'Skipping non-Google-Doc URL'); + return { changed: false }; + } + + // Fetch content + const { content, error, status } = await fetchGoogleDocContent(doc.document_url); + + if (status !== 'success') { + // Update status to reflect the error + await workingGroupDb.updateDocumentIndex( + doc.id, + doc.content_hash || '', + doc.last_content || '', + status, + error + ); + return { changed: false, error }; + } + + // Handle empty content case - this shouldn't normally happen but protects against + // edge cases where a document exists but has no readable content + if (!content || content.trim().length === 0) { + logger.warn({ documentId: doc.id, title: doc.title }, 'Document has empty content'); + await workingGroupDb.updateDocumentIndex( + doc.id, + doc.content_hash || '', + doc.last_content || '', + 'success', + undefined + ); + return { changed: false }; + } + + // Compute hash to detect changes + const newHash = computeContentHash(content); + const hasChanged = newHash !== doc.content_hash; + + // Update the document index + await workingGroupDb.updateDocumentIndex( + doc.id, + newHash, + content, + 'success' + ); + + // If content changed, log activity and generate change summary + if (hasChanged && doc.content_hash) { + const changeSummary = await generateChangeSummary( + doc.title, + doc.last_content || '', + content + ); + + await workingGroupDb.logDocumentActivity( + doc.id, + doc.working_group_id, + 'content_changed', + doc.content_hash, + newHash, + changeSummary + ); + + logger.info({ + documentId: doc.id, + title: doc.title, + changeSummary, + }, 'Document content changed'); + } else if (!doc.content_hash) { + // First index + await workingGroupDb.logDocumentActivity( + doc.id, + doc.working_group_id, + 'indexed' + ); + } + + // Generate/update summary if needed + let summary: string | undefined; + const needsSummary = !doc.document_summary || hasChanged; + + if (needsSummary) { + try { + summary = await generateDocumentSummary(doc.title, content); + await workingGroupDb.updateDocumentSummary(doc.id, summary); + } catch (summaryError) { + logger.warn({ err: summaryError, documentId: doc.id }, 'Failed to generate document summary'); + } + } + + return { changed: hasChanged, summary }; +} + +/** + * Run the document indexer job + */ +export async function runDocumentIndexerJob(options: { + batchSize?: number; +} = {}): Promise<DocumentIndexResult> { + const { batchSize = 20 } = options; + + logger.info({ batchSize }, 'Running committee document indexer job'); + + const result: DocumentIndexResult = { + documentsChecked: 0, + documentsChanged: 0, + documentsError: 0, + summariesGenerated: 0, + }; + + try { + // Get documents that need indexing + const documents = await workingGroupDb.getDocumentsPendingIndex(batchSize); + result.documentsChecked = documents.length; + + if (documents.length === 0) { + logger.debug('No documents pending index'); + return result; + } + + logger.info({ count: documents.length }, 'Processing documents for indexing'); + + // Process each document + for (const doc of documents) { + try { + const { changed, error, summary } = await indexDocument(doc); + + if (error) { + result.documentsError++; + } else if (changed) { + result.documentsChanged++; + } + + if (summary) { + result.summariesGenerated++; + } + + // Small delay between documents to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (docError) { + logger.error({ err: docError, documentId: doc.id }, 'Failed to index document'); + result.documentsError++; + } + } + + logger.info(result, 'Committee document indexer job completed'); + return result; + } catch (error) { + logger.error({ err: error }, 'Committee document indexer job failed'); + throw error; + } +} + +/** + * Force re-index a specific document + */ +export async function reindexDocument(documentId: string): Promise<{ + success: boolean; + error?: string; +}> { + const doc = await workingGroupDb.getDocumentById(documentId); + if (!doc) { + return { success: false, error: 'Document not found' }; + } + + try { + await indexDocument(doc); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/server/src/addie/jobs/committee-summary-generator.ts b/server/src/addie/jobs/committee-summary-generator.ts new file mode 100644 index 0000000000..7eff937e69 --- /dev/null +++ b/server/src/addie/jobs/committee-summary-generator.ts @@ -0,0 +1,286 @@ +/** + * Committee Summary Generator Job + * + * Periodically generates AI-powered activity summaries for committees. + * Combines information from tracked documents, posts, and activity logs. + * + * Types of summaries: + * - 'overview': General description of the committee and its purpose + * - 'activity': Recent activity including document updates and posts + * - 'changes': Summary of what changed since last update + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { logger } from '../../logger.js'; +import { getPool } from '../../db/client.js'; +import { WorkingGroupDatabase } from '../../db/working-group-db.js'; +import type { CommitteeSummaryType } from '../../types.js'; + +const workingGroupDb = new WorkingGroupDatabase(); + +// Use same model as main Addie assistant +const SUMMARIZER_MODEL = process.env.ADDIE_MODEL || 'claude-sonnet-4-20250514'; + +export interface SummaryGeneratorResult { + committeesProcessed: number; + summariesGenerated: number; + errors: number; +} + +interface InputSource { + type: string; + id: string; + title: string; +} + +/** + * Get recent posts for a working group + */ +async function getRecentPosts(workingGroupId: string, limit = 10): Promise<Array<{ + id: string; + title: string; + excerpt?: string; + published_at?: Date; +}>> { + const pool = getPool(); + const result = await pool.query( + `SELECT id, title, excerpt, published_at + FROM perspectives + WHERE working_group_id = $1 AND status = 'published' + ORDER BY published_at DESC NULLS LAST + LIMIT $2`, + [workingGroupId, limit] + ); + return result.rows; +} + +/** + * Generate an activity summary for a committee + */ +async function generateActivitySummary( + committeeName: string, + committeeDescription: string | undefined, + documents: Array<{ title: string; summary?: string; last_modified_at?: Date }>, + posts: Array<{ title: string; excerpt?: string; published_at?: Date }>, + activity: Array<{ activity_type: string; change_summary?: string; detected_at: Date; document_title?: string }> +): Promise<string> { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY not configured'); + } + + const client = new Anthropic({ apiKey }); + + // Build context about the committee + const documentsSection = documents.length > 0 + ? `\nTracked Documents:\n${documents.map(d => + `- "${d.title}"${d.summary ? `: ${d.summary}` : ''}${d.last_modified_at ? ` (updated ${formatRelativeDate(d.last_modified_at)})` : ''}` + ).join('\n')}` + : ''; + + const postsSection = posts.length > 0 + ? `\nRecent Posts:\n${posts.map(p => + `- "${p.title}"${p.excerpt ? `: ${p.excerpt.substring(0, 100)}...` : ''}` + ).join('\n')}` + : ''; + + const recentChanges = activity + .filter(a => a.activity_type === 'content_changed' && a.change_summary) + .slice(0, 5); + + const changesSection = recentChanges.length > 0 + ? `\nRecent Document Updates:\n${recentChanges.map(a => + `- ${a.document_title || 'Document'}: ${a.change_summary}` + ).join('\n')}` + : ''; + + const systemPrompt = `You are generating an activity summary for a working group at AgenticAdvertising.org. +Write a concise summary (3-5 sentences) of what this group is working on and any recent activity. +Be informative but brief. Use a professional, neutral tone. +If there's no recent activity, focus on describing the group's purpose and tracked documents.`; + + const userPrompt = `Committee: ${committeeName} +${committeeDescription ? `Description: ${committeeDescription}\n` : ''} +${documentsSection}${postsSection}${changesSection} + +Generate a brief activity summary for this committee.`; + + const response = await client.messages.create({ + model: SUMMARIZER_MODEL, + max_tokens: 400, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }); + + const textContent = response.content.find(block => block.type === 'text'); + return textContent?.text || 'No activity summary available.'; +} + +/** + * Format a date as relative time + */ +function formatRelativeDate(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - new Date(date).getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'today'; + if (diffDays === 1) return 'yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; + return `${Math.floor(diffDays / 30)} months ago`; +} + +/** + * Generate summary for a single committee + */ +async function generateCommitteeSummary( + workingGroupId: string, + summaryType: CommitteeSummaryType +): Promise<void> { + // Get committee details + const group = await workingGroupDb.getWorkingGroupById(workingGroupId); + if (!group) { + throw new Error(`Working group not found: ${workingGroupId}`); + } + + // Gather input sources + const documents = await workingGroupDb.getDocumentsByWorkingGroup(workingGroupId); + const posts = await getRecentPosts(workingGroupId); + const activity = await workingGroupDb.getRecentActivity(workingGroupId); + + // Track what we used as inputs + const inputSources: InputSource[] = [ + ...documents.map(d => ({ type: 'document', id: d.id, title: d.title })), + ...posts.map(p => ({ type: 'post', id: p.id, title: p.title })), + ]; + + // Generate the summary based on type + let summaryText: string; + + switch (summaryType) { + case 'activity': + summaryText = await generateActivitySummary( + group.name, + group.description, + documents.map(d => ({ + title: d.title, + summary: d.document_summary ?? undefined, + last_modified_at: d.last_modified_at ?? undefined, + })), + posts, + activity as Array<{ activity_type: string; change_summary?: string; detected_at: Date; document_title?: string }> + ); + break; + + case 'overview': + // For overview, just use the committee description as-is or generate one + summaryText = group.description || `${group.name} is a working group at AgenticAdvertising.org.`; + break; + + case 'changes': + // For changes, focus on recent activity + const recentChanges = activity + .filter(a => a.activity_type === 'content_changed') + .slice(0, 5); + + if (recentChanges.length === 0) { + summaryText = 'No recent document changes.'; + } else { + summaryText = recentChanges.map(a => + `${(a as any).document_title || 'Document'}: ${(a as any).change_summary || 'Updated'}` + ).join('\n'); + } + break; + + default: + throw new Error(`Unknown summary type: ${summaryType}`); + } + + // Save the summary + await workingGroupDb.createSummary( + workingGroupId, + summaryType, + summaryText, + inputSources, + undefined, // time_period_start + undefined, // time_period_end + 'addie' + ); + + logger.info({ + workingGroupId, + groupName: group.name, + summaryType, + inputSourceCount: inputSources.length, + }, 'Generated committee summary'); +} + +/** + * Run the summary generator job + */ +export async function runSummaryGeneratorJob(options: { + batchSize?: number; + summaryType?: CommitteeSummaryType; +} = {}): Promise<SummaryGeneratorResult> { + const { batchSize = 10, summaryType = 'activity' } = options; + + logger.info({ batchSize, summaryType }, 'Running committee summary generator job'); + + const result: SummaryGeneratorResult = { + committeesProcessed: 0, + summariesGenerated: 0, + errors: 0, + }; + + try { + // Get committees that need summary refresh + const committeeIds = await workingGroupDb.getWorkingGroupsNeedingSummaryRefresh(batchSize); + result.committeesProcessed = committeeIds.length; + + if (committeeIds.length === 0) { + logger.debug('No committees need summary refresh'); + return result; + } + + logger.info({ count: committeeIds.length }, 'Processing committees for summary generation'); + + // Process each committee + for (const workingGroupId of committeeIds) { + try { + await generateCommitteeSummary(workingGroupId, summaryType); + result.summariesGenerated++; + + // Small delay between committees + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + logger.error({ err: error, workingGroupId }, 'Failed to generate committee summary'); + result.errors++; + } + } + + logger.info(result, 'Committee summary generator job completed'); + return result; + } catch (error) { + logger.error({ err: error }, 'Committee summary generator job failed'); + throw error; + } +} + +/** + * Force regenerate summary for a specific committee + */ +export async function regenerateCommitteeSummary( + workingGroupId: string, + summaryType: CommitteeSummaryType = 'activity' +): Promise<{ success: boolean; error?: string }> { + try { + await generateCommitteeSummary(workingGroupId, summaryType); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/server/src/addie/jobs/scheduler.ts b/server/src/addie/jobs/scheduler.ts new file mode 100644 index 0000000000..226196a5e6 --- /dev/null +++ b/server/src/addie/jobs/scheduler.ts @@ -0,0 +1,159 @@ +/** + * Job Scheduler + * + * Centralized management of scheduled background jobs. + * This module handles starting, stopping, and tracking all periodic jobs. + */ + +import { logger as baseLogger } from '../../logger.js'; +import { runDocumentIndexerJob } from './committee-document-indexer.js'; +import { runSummaryGeneratorJob } from './committee-summary-generator.js'; + +const logger = baseLogger.child({ module: 'job-scheduler' }); + +interface ScheduledJob { + name: string; + intervalId: NodeJS.Timeout | null; + initialTimeoutId: NodeJS.Timeout | null; +} + +/** + * Job Scheduler singleton + */ +class JobScheduler { + private jobs: Map<string, ScheduledJob> = new Map(); + + /** + * Start the committee document indexer job + * Indexes Google Docs tracked by committees, detects changes, generates summaries + */ + startDocumentIndexer(): void { + const JOB_NAME = 'document-indexer'; + const INTERVAL_MINUTES = 60; // Check every hour + const INITIAL_DELAY_MS = 60000; // 1 minute delay on startup + + const job: ScheduledJob = { + name: JOB_NAME, + intervalId: null, + initialTimeoutId: null, + }; + + // Run after a delay on startup + job.initialTimeoutId = setTimeout(async () => { + try { + const result = await runDocumentIndexerJob({ batchSize: 20 }); + if (result.documentsChecked > 0) { + logger.info(result, 'Document indexer: initial run completed'); + } + } catch (err) { + logger.error({ err }, 'Document indexer: initial run failed'); + } + }, INITIAL_DELAY_MS); + + // Then run periodically + job.intervalId = setInterval(async () => { + try { + const result = await runDocumentIndexerJob({ batchSize: 20 }); + if (result.documentsChecked > 0) { + logger.info(result, 'Document indexer: job completed'); + } + } catch (err) { + logger.error({ err }, 'Document indexer: job failed'); + } + }, INTERVAL_MINUTES * 60 * 1000); + + this.jobs.set(JOB_NAME, job); + logger.debug({ intervalMinutes: INTERVAL_MINUTES }, 'Document indexer job started'); + } + + /** + * Start the committee summary generator job + * Generates AI-powered activity summaries for committees + */ + startSummaryGenerator(): void { + const JOB_NAME = 'summary-generator'; + const INTERVAL_HOURS = 24; // Once per day + const INITIAL_DELAY_MS = 300000; // 5 minute delay on startup + + const job: ScheduledJob = { + name: JOB_NAME, + intervalId: null, + initialTimeoutId: null, + }; + + // Run after a longer delay on startup + job.initialTimeoutId = setTimeout(async () => { + try { + const result = await runSummaryGeneratorJob({ batchSize: 10 }); + if (result.summariesGenerated > 0) { + logger.info(result, 'Summary generator: initial run completed'); + } + } catch (err) { + logger.error({ err }, 'Summary generator: initial run failed'); + } + }, INITIAL_DELAY_MS); + + // Then run periodically + job.intervalId = setInterval(async () => { + try { + const result = await runSummaryGeneratorJob({ batchSize: 10 }); + if (result.summariesGenerated > 0) { + logger.info(result, 'Summary generator: job completed'); + } + } catch (err) { + logger.error({ err }, 'Summary generator: job failed'); + } + }, INTERVAL_HOURS * 60 * 60 * 1000); + + this.jobs.set(JOB_NAME, job); + logger.debug({ intervalHours: INTERVAL_HOURS }, 'Summary generator job started'); + } + + /** + * Stop the document indexer job + */ + stopDocumentIndexer(): void { + this.stopJob('document-indexer'); + } + + /** + * Stop the summary generator job + */ + stopSummaryGenerator(): void { + this.stopJob('summary-generator'); + } + + /** + * Stop a specific job by name + */ + private stopJob(name: string): void { + const job = this.jobs.get(name); + if (!job) return; + + if (job.initialTimeoutId) { + clearTimeout(job.initialTimeoutId); + job.initialTimeoutId = null; + } + + if (job.intervalId) { + clearInterval(job.intervalId); + job.intervalId = null; + } + + this.jobs.delete(name); + logger.info({ jobName: name }, 'Job stopped'); + } + + /** + * Stop all running jobs + */ + stopAll(): void { + for (const name of this.jobs.keys()) { + this.stopJob(name); + } + logger.info('All scheduled jobs stopped'); + } +} + +// Export singleton instance +export const jobScheduler = new JobScheduler(); diff --git a/server/src/db/migrations/159_committee_documents.sql b/server/src/db/migrations/159_committee_documents.sql new file mode 100644 index 0000000000..8d83b600cc --- /dev/null +++ b/server/src/db/migrations/159_committee_documents.sql @@ -0,0 +1,132 @@ +-- Migration: 159_committee_documents.sql +-- Tracked documents and activity summaries for committees/working groups +-- Enables monitoring Google Docs and other external documents for changes + +-- ============================================================================= +-- 1. Committee Documents - external documents tracked by working groups +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS committee_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + working_group_id UUID NOT NULL REFERENCES working_groups(id) ON DELETE CASCADE, + + -- Document identification + title VARCHAR(500) NOT NULL, + description TEXT, -- Brief description of what this document is for + document_url TEXT NOT NULL, -- Full URL (Google Docs, etc.) + document_type VARCHAR(50) NOT NULL DEFAULT 'google_doc' + CHECK (document_type IN ('google_doc', 'google_sheet', 'external_link', 'pdf', 'other')), + + -- Display settings + display_order INTEGER DEFAULT 0, -- For ordering on the page + is_featured BOOLEAN DEFAULT FALSE, -- Show prominently at top + + -- Content tracking (for change detection) + content_hash VARCHAR(64), -- SHA-256 hash of last indexed content + last_content TEXT, -- Cached content from last successful read + last_indexed_at TIMESTAMPTZ, -- When we last successfully read the content + last_modified_at TIMESTAMPTZ, -- When content actually changed (detected via hash) + + -- AI-generated summary of the document + document_summary TEXT, + summary_generated_at TIMESTAMPTZ, + + -- Access tracking + index_status VARCHAR(50) NOT NULL DEFAULT 'pending' + CHECK (index_status IN ('pending', 'success', 'access_denied', 'error', 'disabled')), + index_error TEXT, -- Error message if indexing failed + + -- Metadata + added_by_user_id VARCHAR(255), -- Who added this document + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_committee_documents_working_group ON committee_documents(working_group_id); +CREATE INDEX IF NOT EXISTS idx_committee_documents_status ON committee_documents(index_status); +CREATE INDEX IF NOT EXISTS idx_committee_documents_display ON committee_documents(working_group_id, display_order); +-- Composite index for the pending documents query (document_type + index_status + last_indexed_at) +CREATE INDEX IF NOT EXISTS idx_committee_documents_pending_index ON committee_documents(document_type, index_status, last_indexed_at ASC NULLS FIRST); + +-- Updated at trigger +CREATE TRIGGER update_committee_documents_updated_at + BEFORE UPDATE ON committee_documents + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +COMMENT ON TABLE committee_documents IS 'External documents (Google Docs, etc.) tracked by committees for activity monitoring'; +COMMENT ON COLUMN committee_documents.content_hash IS 'SHA-256 hash to detect content changes without storing full content twice'; +COMMENT ON COLUMN committee_documents.last_content IS 'Cached content for generating summaries and detecting changes'; +COMMENT ON COLUMN committee_documents.document_summary IS 'AI-generated summary of the document contents'; + +-- ============================================================================= +-- 2. Committee Summaries - AI-generated activity summaries for working groups +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS committee_summaries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + working_group_id UUID NOT NULL REFERENCES working_groups(id) ON DELETE CASCADE, + + -- Summary content + summary_type VARCHAR(50) NOT NULL DEFAULT 'activity' + CHECK (summary_type IN ('activity', 'overview', 'changes')), + summary_text TEXT NOT NULL, + + -- Summary metadata + time_period_start TIMESTAMPTZ, -- For activity summaries: what period this covers + time_period_end TIMESTAMPTZ, + + -- What inputs were used to generate this summary + input_sources JSONB NOT NULL DEFAULT '[]', -- Array of {type, id, title} objects + + -- Generation tracking + generated_at TIMESTAMPTZ DEFAULT NOW(), + generated_by VARCHAR(50) DEFAULT 'addie', -- 'addie', 'manual', etc. + + -- Versioning - keep history of summaries + is_current BOOLEAN DEFAULT TRUE, + superseded_by UUID REFERENCES committee_summaries(id), + superseded_at TIMESTAMPTZ +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_committee_summaries_working_group ON committee_summaries(working_group_id); +CREATE INDEX IF NOT EXISTS idx_committee_summaries_current ON committee_summaries(working_group_id, is_current) WHERE is_current = TRUE; +CREATE INDEX IF NOT EXISTS idx_committee_summaries_type ON committee_summaries(working_group_id, summary_type, is_current); + +COMMENT ON TABLE committee_summaries IS 'AI-generated activity summaries for working groups'; +COMMENT ON COLUMN committee_summaries.is_current IS 'TRUE for the latest summary of each type; superseded summaries have FALSE'; +COMMENT ON COLUMN committee_summaries.input_sources IS 'JSON array tracking what documents/posts were used to generate this summary'; + +-- ============================================================================= +-- 3. Document Activity Log - track changes over time +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS committee_document_activity ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES committee_documents(id) ON DELETE CASCADE, + working_group_id UUID NOT NULL REFERENCES working_groups(id) ON DELETE CASCADE, + + -- What happened + activity_type VARCHAR(50) NOT NULL + CHECK (activity_type IN ('indexed', 'content_changed', 'access_lost', 'access_restored', 'error')), + + -- Details about the change + content_hash_before VARCHAR(64), + content_hash_after VARCHAR(64), + change_summary TEXT, -- AI-generated description of what changed + + -- Tracking + detected_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_document_activity_document ON committee_document_activity(document_id); +CREATE INDEX IF NOT EXISTS idx_document_activity_working_group ON committee_document_activity(working_group_id, detected_at DESC); + +COMMENT ON TABLE committee_document_activity IS 'Log of document changes for activity feeds and auditing'; + +-- ============================================================================= +-- Done +-- ============================================================================= diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index 8623a1ca5e..e94df5c44e 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -11,6 +11,14 @@ import type { CommitteeType, EventInterestLevel, EventInterestSource, + CommitteeDocument, + CreateCommitteeDocumentInput, + UpdateCommitteeDocumentInput, + CommitteeSummary, + CommitteeSummaryType, + CommitteeDocumentActivity, + DocumentActivityType, + DocumentIndexStatus, } from '../types.js'; /** @@ -1366,4 +1374,348 @@ export class WorkingGroupDatabase { return { members, stats }; } + + // ============== Committee Documents ============== + + /** + * Create a new committee document + */ + async createDocument(input: CreateCommitteeDocumentInput): Promise<CommitteeDocument> { + // Detect document type from URL if not provided + const documentType = input.document_type || this.detectDocumentType(input.document_url); + + const result = await query<CommitteeDocument>( + `INSERT INTO committee_documents ( + working_group_id, title, description, document_url, document_type, + display_order, is_featured, added_by_user_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + input.working_group_id, + input.title, + input.description || null, + input.document_url, + documentType, + input.display_order ?? 0, + input.is_featured ?? false, + input.added_by_user_id || null, + ] + ); + + return result.rows[0]; + } + + /** + * Detect document type from URL + */ + private detectDocumentType(url: string): string { + try { + const parsed = new URL(url); + if (parsed.hostname === 'docs.google.com') { + if (parsed.pathname.includes('/document/')) return 'google_doc'; + if (parsed.pathname.includes('/spreadsheets/')) return 'google_sheet'; + } + if (parsed.hostname === 'drive.google.com') return 'google_doc'; + if (url.toLowerCase().endsWith('.pdf')) return 'pdf'; + return 'external_link'; + } catch { + return 'external_link'; + } + } + + /** + * Get document by ID + */ + async getDocumentById(id: string): Promise<CommitteeDocument | null> { + const result = await query<CommitteeDocument>( + 'SELECT * FROM committee_documents WHERE id = $1', + [id] + ); + return result.rows[0] || null; + } + + /** + * Get all documents for a working group + */ + async getDocumentsByWorkingGroup(workingGroupId: string): Promise<CommitteeDocument[]> { + const result = await query<CommitteeDocument>( + `SELECT * FROM committee_documents + WHERE working_group_id = $1 + ORDER BY is_featured DESC, display_order ASC, created_at DESC`, + [workingGroupId] + ); + return result.rows; + } + + /** + * Get documents that need indexing (pending or due for refresh) + */ + async getDocumentsPendingIndex(limit = 50): Promise<CommitteeDocument[]> { + const result = await query<CommitteeDocument>( + `SELECT cd.* FROM committee_documents cd + JOIN working_groups wg ON wg.id = cd.working_group_id + WHERE wg.status = 'active' + AND cd.index_status IN ('pending', 'success') + AND cd.document_type IN ('google_doc', 'google_sheet') + AND ( + cd.last_indexed_at IS NULL + OR cd.last_indexed_at < NOW() - INTERVAL '1 hour' + ) + ORDER BY cd.last_indexed_at ASC NULLS FIRST + LIMIT $1`, + [limit] + ); + return result.rows; + } + + /** + * Update document with new indexed content + */ + async updateDocumentIndex( + id: string, + contentHash: string, + content: string, + status: DocumentIndexStatus, + error?: string + ): Promise<CommitteeDocument | null> { + const result = await query<CommitteeDocument>( + `UPDATE committee_documents + SET content_hash = $2::varchar(64), + last_content = $3, + index_status = $4, + index_error = $5, + last_indexed_at = NOW(), + last_modified_at = CASE + WHEN content_hash IS DISTINCT FROM $2::varchar(64) THEN NOW() + ELSE last_modified_at + END + WHERE id = $1 + RETURNING *`, + [id, contentHash, content, status, error || null] + ); + return result.rows[0] || null; + } + + /** + * Update document summary + */ + async updateDocumentSummary(id: string, summary: string): Promise<CommitteeDocument | null> { + const result = await query<CommitteeDocument>( + `UPDATE committee_documents + SET document_summary = $2, summary_generated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, summary] + ); + return result.rows[0] || null; + } + + /** + * Update a document + */ + async updateDocument(id: string, updates: UpdateCommitteeDocumentInput): Promise<CommitteeDocument | null> { + const setClauses: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + const fieldMap: Record<string, string> = { + title: 'title', + description: 'description', + document_url: 'document_url', + document_type: 'document_type', + display_order: 'display_order', + is_featured: 'is_featured', + }; + + for (const [key, value] of Object.entries(updates)) { + const columnName = fieldMap[key]; + if (!columnName) continue; + + setClauses.push(`${columnName} = $${paramIndex}`); + params.push(value ?? null); + paramIndex++; + } + + if (setClauses.length === 0) { + return this.getDocumentById(id); + } + + params.push(id); + const result = await query<CommitteeDocument>( + `UPDATE committee_documents SET ${setClauses.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + params + ); + + return result.rows[0] || null; + } + + /** + * Delete a document + */ + async deleteDocument(id: string): Promise<boolean> { + const result = await query( + 'DELETE FROM committee_documents WHERE id = $1', + [id] + ); + return (result.rowCount ?? 0) > 0; + } + + // ============== Committee Summaries ============== + + /** + * Create a new summary (marks previous of same type as superseded) + */ + async createSummary( + workingGroupId: string, + summaryType: CommitteeSummaryType, + summaryText: string, + inputSources: Array<{ type: string; id: string; title: string }>, + timePeriodStart?: Date, + timePeriodEnd?: Date, + generatedBy = 'addie' + ): Promise<CommitteeSummary> { + // Mark previous current summaries of this type as superseded + await query( + `UPDATE committee_summaries + SET is_current = FALSE, superseded_at = NOW() + WHERE working_group_id = $1 + AND summary_type = $2 + AND is_current = TRUE`, + [workingGroupId, summaryType] + ); + + const result = await query<CommitteeSummary>( + `INSERT INTO committee_summaries ( + working_group_id, summary_type, summary_text, input_sources, + time_period_start, time_period_end, generated_by, is_current + ) VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE) + RETURNING *`, + [ + workingGroupId, + summaryType, + summaryText, + JSON.stringify(inputSources), + timePeriodStart || null, + timePeriodEnd || null, + generatedBy, + ] + ); + + // Update superseded_by on old summaries + if (result.rows[0]) { + await query( + `UPDATE committee_summaries + SET superseded_by = $1 + WHERE working_group_id = $2 + AND summary_type = $3 + AND is_current = FALSE + AND superseded_by IS NULL`, + [result.rows[0].id, workingGroupId, summaryType] + ); + } + + return result.rows[0]; + } + + /** + * Get current summary of a specific type + */ + async getCurrentSummary( + workingGroupId: string, + summaryType: CommitteeSummaryType + ): Promise<CommitteeSummary | null> { + const result = await query<CommitteeSummary>( + `SELECT * FROM committee_summaries + WHERE working_group_id = $1 + AND summary_type = $2 + AND is_current = TRUE`, + [workingGroupId, summaryType] + ); + return result.rows[0] || null; + } + + /** + * Get all current summaries for a working group + */ + async getCurrentSummaries(workingGroupId: string): Promise<CommitteeSummary[]> { + const result = await query<CommitteeSummary>( + `SELECT * FROM committee_summaries + WHERE working_group_id = $1 AND is_current = TRUE + ORDER BY summary_type`, + [workingGroupId] + ); + return result.rows; + } + + /** + * Get working groups that need summary refresh + */ + async getWorkingGroupsNeedingSummaryRefresh(limit = 20): Promise<string[]> { + const result = await query<{ id: string }>( + `SELECT DISTINCT wg.id + FROM working_groups wg + LEFT JOIN committee_summaries cs ON cs.working_group_id = wg.id + AND cs.summary_type = 'activity' + AND cs.is_current = TRUE + WHERE wg.status = 'active' + AND wg.committee_type != 'industry_gathering' + AND ( + cs.id IS NULL + OR cs.generated_at < NOW() - INTERVAL '24 hours' + ) + ORDER BY cs.generated_at ASC NULLS FIRST + LIMIT $1`, + [limit] + ); + return result.rows.map(r => r.id); + } + + // ============== Document Activity ============== + + /** + * Log document activity + */ + async logDocumentActivity( + documentId: string, + workingGroupId: string, + activityType: DocumentActivityType, + contentHashBefore?: string, + contentHashAfter?: string, + changeSummary?: string + ): Promise<CommitteeDocumentActivity> { + const result = await query<CommitteeDocumentActivity>( + `INSERT INTO committee_document_activity ( + document_id, working_group_id, activity_type, + content_hash_before, content_hash_after, change_summary + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + documentId, + workingGroupId, + activityType, + contentHashBefore || null, + contentHashAfter || null, + changeSummary || null, + ] + ); + return result.rows[0]; + } + + /** + * Get recent activity for a working group + */ + async getRecentActivity(workingGroupId: string, limit = 20): Promise<CommitteeDocumentActivity[]> { + const result = await query<CommitteeDocumentActivity>( + `SELECT cda.*, cd.title as document_title + FROM committee_document_activity cda + JOIN committee_documents cd ON cd.id = cda.document_id + WHERE cda.working_group_id = $1 + ORDER BY cda.detected_at DESC + LIMIT $2`, + [workingGroupId, limit] + ); + return result.rows; + } } diff --git a/server/src/http.ts b/server/src/http.ts index 7ea8906734..487731c0ef 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -68,6 +68,7 @@ import { sendChannelMessage } from "./slack/client.js"; import { runTaskReminderJob } from "./addie/jobs/task-reminder.js"; import { runEngagementScoringJob } from "./addie/jobs/engagement-scoring.js"; import { runGoalFollowUpJob } from "./addie/jobs/goal-follow-up.js"; +import { jobScheduler } from "./addie/jobs/scheduler.js"; import { notifyJoinRequest, notifyMemberAdded, notifySubscriptionThankYou } from "./slack/org-group-dm.js"; const __filename = fileURLToPath(import.meta.url); @@ -6817,6 +6818,10 @@ Disallow: /api/admin/ // Sends follow-up messages and reconciles goal outcomes this.startGoalFollowUp(); + // Start committee document jobs via scheduler + jobScheduler.startDocumentIndexer(); + jobScheduler.startSummaryGenerator(); + this.server = this.app.listen(port, () => { logger.info({ port, @@ -7154,6 +7159,9 @@ Disallow: /api/admin/ logger.info('Goal follow-up job stopped'); } + // Stop committee document jobs via scheduler + jobScheduler.stopAll(); + // Close HTTP server if (this.server) { await new Promise<void>((resolve, reject) => { diff --git a/server/src/routes/committees.ts b/server/src/routes/committees.ts index d4cee01815..54c231fe6a 100644 --- a/server/src/routes/committees.ts +++ b/server/src/routes/committees.ts @@ -16,6 +16,7 @@ import { invalidateMemberContextCache } from "../addie/index.js"; import { syncWorkingGroupMembersFromSlack, syncAllWorkingGroupMembersFromSlack } from "../slack/sync.js"; import { notifyWorkingGroupPost } from "../notifications/slack.js"; import { decodeHtmlEntities } from "../utils/html-entities.js"; +import { reindexDocument } from "../addie/jobs/committee-document-indexer.js"; import { createChannel, setChannelPurpose } from "../slack/client.js"; const logger = createLogger("committee-routes"); @@ -23,6 +24,46 @@ const logger = createLogger("committee-routes"); // UUID validation regex const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// Rate limiting for reindex endpoint (prevent API cost abuse) +const reindexRateLimit = new Map<string, number[]>(); +const REINDEX_RATE_LIMIT = 5; // Max requests +const REINDEX_RATE_WINDOW = 60 * 1000; // Per minute + +function checkReindexRateLimit(userId: string): boolean { + const now = Date.now(); + const requests = reindexRateLimit.get(userId) || []; + const recentRequests = requests.filter(time => now - time < REINDEX_RATE_WINDOW); + + if (recentRequests.length >= REINDEX_RATE_LIMIT) { + return false; + } + + recentRequests.push(now); + reindexRateLimit.set(userId, recentRequests); + return true; +} + +// Allowed document URL patterns (whitelist approach to prevent SSRF) +const ALLOWED_DOCUMENT_DOMAINS = [ + 'docs.google.com', + 'drive.google.com', + 'sheets.google.com', +]; + +function isAllowedDocumentUrl(url: string): boolean { + try { + const parsed = new URL(url); + // Must be HTTPS + if (parsed.protocol !== 'https:') { + return false; + } + // Must be an allowed domain + return ALLOWED_DOCUMENT_DOMAINS.includes(parsed.hostname); + } catch { + return false; + } +} + // Valid committee types const VALID_COMMITTEE_TYPES = ['working_group', 'council', 'chapter', 'governance', 'industry_gathering'] as const; @@ -1372,6 +1413,334 @@ export function createCommitteeRouters(): { } }); + // ========================================================================= + // COMMITTEE DOCUMENT ROUTES (/api/working-groups/:slug/documents) + // ========================================================================= + + // GET /api/working-groups/:slug/documents - Get documents for a committee (public) + publicApiRouter.get('/:slug/documents', optionalAuth, async (req: Request, res: Response) => { + try { + const { slug } = req.params; + + const group = await workingGroupDb.getWorkingGroupBySlug(slug); + if (!group || group.status !== 'active') { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${slug}`, + }); + } + + const documents = await workingGroupDb.getDocumentsByWorkingGroup(group.id); + + // Don't expose internal content to non-leaders + const user = req.user; + const isLeader = user && await workingGroupDb.isLeader(group.id, user.id); + + const publicDocuments = documents.map(doc => ({ + id: doc.id, + title: doc.title, + description: doc.description, + document_url: doc.document_url, + document_type: doc.document_type, + display_order: doc.display_order, + is_featured: doc.is_featured, + last_modified_at: doc.last_modified_at, + document_summary: doc.document_summary, + summary_generated_at: doc.summary_generated_at, + index_status: doc.index_status, + created_at: doc.created_at, + // Only include these for leaders + ...(isLeader && { + index_error: doc.index_error, + last_indexed_at: doc.last_indexed_at, + }), + })); + + res.json({ documents: publicDocuments }); + } catch (error) { + logger.error({ err: error }, 'Get committee documents error'); + res.status(500).json({ + error: 'Failed to get documents', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // GET /api/working-groups/:slug/activity - Get recent activity for a committee + publicApiRouter.get('/:slug/activity', optionalAuth, async (req: Request, res: Response) => { + try { + const { slug } = req.params; + + const group = await workingGroupDb.getWorkingGroupBySlug(slug); + if (!group || group.status !== 'active') { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${slug}`, + }); + } + + const activity = await workingGroupDb.getRecentActivity(group.id); + res.json({ activity }); + } catch (error) { + logger.error({ err: error }, 'Get committee activity error'); + res.status(500).json({ + error: 'Failed to get activity', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // GET /api/working-groups/:slug/summary - Get current committee summary + publicApiRouter.get('/:slug/summary', optionalAuth, async (req: Request, res: Response) => { + try { + const { slug } = req.params; + const summaryType = (req.query.type as string) || 'activity'; + + const group = await workingGroupDb.getWorkingGroupBySlug(slug); + if (!group || group.status !== 'active') { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${slug}`, + }); + } + + if (!['activity', 'overview', 'changes'].includes(summaryType)) { + return res.status(400).json({ + error: 'Invalid summary type', + message: 'Summary type must be: activity, overview, or changes', + }); + } + + const summary = await workingGroupDb.getCurrentSummary( + group.id, + summaryType as 'activity' | 'overview' | 'changes' + ); + + res.json({ summary }); + } catch (error) { + logger.error({ err: error }, 'Get committee summary error'); + res.status(500).json({ + error: 'Failed to get summary', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/working-groups/:slug/documents - Add a document (leaders only) + publicApiRouter.post('/:slug/documents', requireAuth, requireWorkingGroupLeader, async (req: Request, res: Response) => { + try { + const { slug } = req.params; + const { title, description, document_url, document_type, display_order, is_featured } = req.body; + const user = req.user!; + + const group = await workingGroupDb.getWorkingGroupBySlug(slug); + if (!group) { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${slug}`, + }); + } + + if (!title || !document_url) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'Title and document_url are required', + }); + } + + // Strict URL validation to prevent SSRF - only allow trusted domains + if (!isAllowedDocumentUrl(document_url)) { + return res.status(400).json({ + error: 'Invalid document URL', + message: 'Only Google Docs, Sheets, and Drive URLs are supported', + }); + } + + const document = await workingGroupDb.createDocument({ + working_group_id: group.id, + title, + description, + document_url, + document_type, + display_order: display_order ?? 0, + is_featured: is_featured ?? false, + added_by_user_id: user.id, + }); + + logger.info({ documentId: document.id, groupSlug: slug, userId: user.id }, 'Committee document created'); + + res.status(201).json({ document }); + } catch (error) { + logger.error({ err: error }, 'Create committee document error'); + res.status(500).json({ + error: 'Failed to create document', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // PUT /api/working-groups/:slug/documents/:documentId - Update a document (leaders only) + publicApiRouter.put('/:slug/documents/:documentId', requireAuth, requireWorkingGroupLeader, async (req: Request, res: Response) => { + try { + const { slug, documentId } = req.params; + const { title, description, document_url, document_type, display_order, is_featured } = req.body; + + if (!UUID_REGEX.test(documentId)) { + return res.status(400).json({ + error: 'Invalid document ID', + message: 'Document ID must be a valid UUID', + }); + } + + const group = await workingGroupDb.getWorkingGroupBySlug(slug); + if (!group) { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${slug}`, + }); + } + + const existingDoc = await workingGroupDb.getDocumentById(documentId); + if (!existingDoc || existingDoc.working_group_id !== group.id) { + return res.status(404).json({ + error: 'Document not found', + message: 'Document not found in this committee', + }); + } + + // Validate URL if provided - strict validation to prevent SSRF + if (document_url && !isAllowedDocumentUrl(document_url)) { + return res.status(400).json({ + error: 'Invalid document URL', + message: 'Only Google Docs, Sheets, and Drive URLs are supported', + }); + } + + const document = await workingGroupDb.updateDocument(documentId, { + title, + description, + document_url, + document_type, + display_order, + is_featured, + }); + + res.json({ document }); + } catch (error) { + logger.error({ err: error }, 'Update committee document error'); + res.status(500).json({ + error: 'Failed to update document', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // POST /api/working-groups/:slug/documents/:documentId/reindex - Trigger reindex (leaders only) + publicApiRouter.post('/:slug/documents/:documentId/reindex', requireAuth, requireWorkingGroupLeader, async (req: Request, res: Response) => { + try { + const { slug, documentId } = req.params; + const user = req.user!; + + // Rate limit check to prevent API cost abuse + if (!checkReindexRateLimit(user.id)) { + return res.status(429).json({ + error: 'Rate limit exceeded', + message: 'Too many reindex requests. Please wait a minute before trying again.', + }); + } + + if (!UUID_REGEX.test(documentId)) { + return res.status(400).json({ + error: 'Invalid document ID', + message: 'Document ID must be a valid UUID', + }); + } + + const group = await workingGroupDb.getWorkingGroupBySlug(slug); + if (!group) { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${slug}`, + }); + } + + const existingDoc = await workingGroupDb.getDocumentById(documentId); + if (!existingDoc || existingDoc.working_group_id !== group.id) { + return res.status(404).json({ + error: 'Document not found', + message: 'Document not found in this committee', + }); + } + + const result = await reindexDocument(documentId); + + if (!result.success) { + return res.status(500).json({ + error: 'Reindex failed', + message: result.error, + }); + } + + // Fetch the updated document + const updatedDoc = await workingGroupDb.getDocumentById(documentId); + + logger.info({ documentId, groupSlug: slug }, 'Committee document reindexed'); + + res.json({ + success: true, + document: updatedDoc, + }); + } catch (error) { + logger.error({ err: error }, 'Reindex committee document error'); + res.status(500).json({ + error: 'Failed to reindex document', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // DELETE /api/working-groups/:slug/documents/:documentId - Delete a document (leaders only) + publicApiRouter.delete('/:slug/documents/:documentId', requireAuth, requireWorkingGroupLeader, async (req: Request, res: Response) => { + try { + const { slug, documentId } = req.params; + + if (!UUID_REGEX.test(documentId)) { + return res.status(400).json({ + error: 'Invalid document ID', + message: 'Document ID must be a valid UUID', + }); + } + + const group = await workingGroupDb.getWorkingGroupBySlug(slug); + if (!group) { + return res.status(404).json({ + error: 'Committee not found', + message: `No committee found with slug: ${slug}`, + }); + } + + const existingDoc = await workingGroupDb.getDocumentById(documentId); + if (!existingDoc || existingDoc.working_group_id !== group.id) { + return res.status(404).json({ + error: 'Document not found', + message: 'Document not found in this committee', + }); + } + + await workingGroupDb.deleteDocument(documentId); + + logger.info({ documentId, groupSlug: slug }, 'Committee document deleted'); + + res.json({ success: true }); + } catch (error) { + logger.error({ err: error }, 'Delete committee document error'); + res.status(500).json({ + error: 'Failed to delete document', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // ========================================================================= // LEADER API ROUTES (/api/working-groups/:slug/manage/*) // ========================================================================= diff --git a/server/src/types.ts b/server/src/types.ts index 91e9db3dbf..6e3cea9b61 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -471,6 +471,83 @@ export interface AddWorkingGroupMemberInput { added_by_user_id?: string; } +// Committee Documents Types + +export type CommitteeDocumentType = 'google_doc' | 'google_sheet' | 'external_link' | 'pdf' | 'other'; +export type DocumentIndexStatus = 'pending' | 'success' | 'access_denied' | 'error' | 'disabled'; + +export interface CommitteeDocument { + id: string; + working_group_id: string; + title: string; + description?: string; + document_url: string; + document_type: CommitteeDocumentType; + display_order: number; + is_featured: boolean; + content_hash?: string; + last_content?: string; + last_indexed_at?: Date; + last_modified_at?: Date; + document_summary?: string; + summary_generated_at?: Date; + index_status: DocumentIndexStatus; + index_error?: string; + added_by_user_id?: string; + created_at: Date; + updated_at: Date; +} + +export interface CreateCommitteeDocumentInput { + working_group_id: string; + title: string; + description?: string; + document_url: string; + document_type?: CommitteeDocumentType; + display_order?: number; + is_featured?: boolean; + added_by_user_id?: string; +} + +export interface UpdateCommitteeDocumentInput { + title?: string; + description?: string; + document_url?: string; + document_type?: CommitteeDocumentType; + display_order?: number; + is_featured?: boolean; +} + +export type CommitteeSummaryType = 'activity' | 'overview' | 'changes'; + +export interface CommitteeSummary { + id: string; + working_group_id: string; + summary_type: CommitteeSummaryType; + summary_text: string; + time_period_start?: Date; + time_period_end?: Date; + input_sources: Array<{ type: string; id: string; title: string }>; + generated_at: Date; + generated_by: string; + is_current: boolean; + superseded_by?: string; + superseded_at?: Date; +} + +export type DocumentActivityType = 'indexed' | 'content_changed' | 'access_lost' | 'access_restored' | 'error'; + +export interface CommitteeDocumentActivity { + id: string; + document_id: string; + working_group_id: string; + activity_type: DocumentActivityType; + content_hash_before?: string; + content_hash_after?: string; + change_summary?: string; + detected_at: Date; +} + // Federated Discovery Types /** From f775bdce2092f74d2c375eb65a8a51ec2e88a11a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 19:07:24 -0500 Subject: [PATCH 39/77] feat: add MCP tools for Addie to manage committee documents (full CRUD) (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MCP tools for Addie to manage committee documents (full CRUD) Adds four tools for Addie to manage committee documents: - add_committee_document: Create a new tracked document - list_committee_documents: List documents for a committee - update_committee_document: Update document properties - delete_committee_document: Remove a tracked document All tools require committee leader permissions except list. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: address code review feedback - Add UUID validation before API calls in update/delete handlers - Include document ID in list response for easier copy/paste 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/fresh-crews-wash.md | 2 + server/src/addie/mcp/member-tools.ts | 317 +++++++++++++++++++++++++++ server/src/addie/prompts.ts | 4 + 3 files changed, 323 insertions(+) create mode 100644 .changeset/fresh-crews-wash.md 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/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index b8f189d69a..d3cd669477 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -498,6 +498,114 @@ export const MEMBER_TOOLS: AddieTool[] = [ }, }, + // ============================================ + // COMMITTEE DOCUMENTS + // ============================================ + { + name: 'add_committee_document', + description: + 'Add a Google Docs document to a committee (working group, council, or chapter) for tracking. The document will be automatically indexed and summarized. Only committee leaders can add documents.', + usage_hints: 'use when user wants to add a Google Doc to track for a committee', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'The committee/working group slug (e.g., "governance", "brand-standards-wg")', + }, + title: { + type: 'string', + description: 'A title for the document', + }, + document_url: { + type: 'string', + description: 'The Google Docs URL (must be docs.google.com, sheets.google.com, or drive.google.com)', + }, + description: { + type: 'string', + description: 'Optional description of what the document is for', + }, + is_featured: { + type: 'boolean', + description: 'Whether this is a featured/highlighted document (default: false)', + }, + }, + required: ['committee_slug', 'title', 'document_url'], + }, + }, + { + name: 'list_committee_documents', + description: + 'List documents tracked by a committee. Shows document titles, status, and summaries.', + usage_hints: 'use for "what documents does X group have?", "show governance docs"', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'The committee/working group slug', + }, + }, + required: ['committee_slug'], + }, + }, + { + name: 'update_committee_document', + description: + 'Update a document tracked by a committee. Can change title, description, URL, or featured status. Only committee leaders can update documents.', + usage_hints: 'use when user wants to update/edit a tracked document', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'The committee/working group slug', + }, + document_id: { + type: 'string', + description: 'The document ID to update (UUID)', + }, + title: { + type: 'string', + description: 'New title for the document', + }, + description: { + type: 'string', + description: 'New description for the document', + }, + document_url: { + type: 'string', + description: 'New Google Docs URL (must be docs.google.com, sheets.google.com, or drive.google.com)', + }, + is_featured: { + type: 'boolean', + description: 'Whether this is a featured/highlighted document', + }, + }, + required: ['committee_slug', 'document_id'], + }, + }, + { + name: 'delete_committee_document', + description: + 'Remove a document from a committee. The document will no longer be tracked or displayed. Only committee leaders can delete documents.', + usage_hints: 'use when user wants to remove/delete a tracked document', + input_schema: { + type: 'object', + properties: { + committee_slug: { + type: 'string', + description: 'The committee/working group slug', + }, + document_id: { + type: 'string', + description: 'The document ID to delete (UUID)', + }, + }, + required: ['committee_slug', 'document_id'], + }, + }, + // ============================================ // ACCOUNT LINKING // ============================================ @@ -1656,6 +1764,215 @@ export function createMemberToolHandlers( return `❌ Content rejected. The author will see the following reason:\n\n> ${reason}\n\nThey can revise and resubmit if appropriate.`; }); + // ============================================ + // COMMITTEE DOCUMENTS + // ============================================ + handlers.set('add_committee_document', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to add documents. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const slug = input.committee_slug as string; + const title = input.title as string; + const documentUrl = input.document_url as string; + const description = input.description as string | undefined; + const isFeatured = input.is_featured as boolean | undefined; + + // Validate URL is a Google domain + try { + const url = new URL(documentUrl); + const allowedDomains = ['docs.google.com', 'sheets.google.com', 'drive.google.com']; + if (url.protocol !== 'https:' || !allowedDomains.includes(url.hostname)) { + return `Invalid document URL. Only Google Docs, Sheets, and Drive URLs are supported (https://docs.google.com, sheets.google.com, or drive.google.com).`; + } + } catch { + return 'Invalid URL format. Please provide a valid Google Docs URL.'; + } + + const result = await callApi( + 'POST', + `/api/working-groups/${slug}/documents`, + memberContext, + { + title, + document_url: documentUrl, + description, + is_featured: isFeatured || false, + document_type: documentUrl.includes('sheets.google.com') ? 'google_sheet' : 'google_doc', + } + ); + + if (!result.ok) { + if (result.status === 403) { + return `You're not a leader of the "${slug}" committee. Only committee leaders can add documents.`; + } + if (result.status === 404) { + return `Committee "${slug}" not found. Use list_working_groups to see available committees.`; + } + return `Failed to add document: ${result.error}`; + } + + let response = `✅ Document added to "${slug}"!\n\n`; + response += `**Title:** ${title}\n`; + response += `**URL:** ${documentUrl}\n\n`; + response += `The document will be automatically indexed and summarized within the hour. `; + response += `You can view it at https://agenticadvertising.org/working-groups/${slug}`; + + return response; + }); + + handlers.set('list_committee_documents', async (input) => { + const slug = input.committee_slug as string; + + const result = await callApi('GET', `/api/working-groups/${slug}/documents`, memberContext); + + if (!result.ok) { + if (result.status === 404) { + return `Committee "${slug}" not found. Use list_working_groups to see available committees.`; + } + return `Failed to list documents: ${result.error}`; + } + + const data = result.data as { documents?: Array<{ + id: string; + title: string; + document_url: string; + description?: string; + document_summary?: string; + index_status: string; + is_featured: boolean; + last_modified_at?: string; + }> } | undefined; + const documents = data?.documents || []; + + if (documents.length === 0) { + return `No documents are being tracked for the "${slug}" committee yet.`; + } + + let response = `## Documents for "${slug}"\n\n`; + for (const doc of documents) { + response += `### ${doc.title}${doc.is_featured ? ' ⭐' : ''}\n`; + response += `**ID:** \`${doc.id}\`\n`; + response += `**URL:** ${doc.document_url}\n`; + response += `**Status:** ${doc.index_status}\n`; + if (doc.document_summary) { + response += `**Summary:** ${doc.document_summary}\n`; + } + if (doc.last_modified_at) { + const date = new Date(doc.last_modified_at); + response += `**Last updated:** ${date.toLocaleDateString()}\n`; + } + response += '\n'; + } + + return response; + }); + + handlers.set('update_committee_document', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to update documents. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const slug = input.committee_slug as string; + const documentId = input.document_id as string; + const title = input.title as string | undefined; + const description = input.description as string | undefined; + const documentUrl = input.document_url as string | undefined; + const isFeatured = input.is_featured as boolean | undefined; + + // Validate UUID format before API call + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!UUID_REGEX.test(documentId)) { + return 'Invalid document ID format. Use list_committee_documents to find valid document IDs.'; + } + + // Validate URL if provided + if (documentUrl) { + try { + const url = new URL(documentUrl); + const allowedDomains = ['docs.google.com', 'sheets.google.com', 'drive.google.com']; + if (url.protocol !== 'https:' || !allowedDomains.includes(url.hostname)) { + return `Invalid document URL. Only Google Docs, Sheets, and Drive URLs are supported (https://docs.google.com, sheets.google.com, or drive.google.com).`; + } + } catch { + return 'Invalid URL format. Please provide a valid Google Docs URL.'; + } + } + + // Build update payload with only provided fields + const updateData: Record<string, unknown> = {}; + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (documentUrl !== undefined) { + updateData.document_url = documentUrl; + updateData.document_type = documentUrl.includes('sheets.google.com') ? 'google_sheet' : 'google_doc'; + } + if (isFeatured !== undefined) updateData.is_featured = isFeatured; + + if (Object.keys(updateData).length === 0) { + return 'No fields to update. Please provide at least one field to change (title, description, document_url, or is_featured).'; + } + + const result = await callApi( + 'PUT', + `/api/working-groups/${slug}/documents/${documentId}`, + memberContext, + updateData + ); + + if (!result.ok) { + if (result.status === 403) { + return `You're not a leader of the "${slug}" committee. Only committee leaders can update documents.`; + } + if (result.status === 404) { + return `Document not found. Either the committee "${slug}" doesn't exist or the document ID "${documentId}" is invalid.`; + } + return `Failed to update document: ${result.error}`; + } + + const data = result.data as { document?: { title: string } } | undefined; + const docTitle = data?.document?.title || title || 'Document'; + + let response = `✅ Document updated!\n\n`; + response += `**${docTitle}** has been updated in "${slug}".\n\n`; + response += `View it at https://agenticadvertising.org/working-groups/${slug}`; + + return response; + }); + + handlers.set('delete_committee_document', async (input) => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to delete documents. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const slug = input.committee_slug as string; + const documentId = input.document_id as string; + + // Validate UUID format before API call + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!UUID_REGEX.test(documentId)) { + return 'Invalid document ID format. Use list_committee_documents to find valid document IDs.'; + } + + const result = await callApi( + 'DELETE', + `/api/working-groups/${slug}/documents/${documentId}`, + memberContext + ); + + if (!result.ok) { + if (result.status === 403) { + return `You're not a leader of the "${slug}" committee. Only committee leaders can delete documents.`; + } + if (result.status === 404) { + return `Document not found. Either the committee "${slug}" doesn't exist or the document ID "${documentId}" is invalid.`; + } + return `Failed to delete document: ${result.error}`; + } + + return `✅ Document removed from "${slug}".\n\nThe document will no longer be tracked or displayed on the committee page.`; + }); + // ============================================ // ACCOUNT LINKING // ============================================ diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts index a9083087a6..9f973cb8c8 100644 --- a/server/src/addie/prompts.ts +++ b/server/src/addie/prompts.ts @@ -148,6 +148,10 @@ When users set up agents or publishers, walk through the full verification chain - join_working_group: Join a public group (user-scoped) - get_my_working_groups: Show user's memberships - create_working_group_post: Post in a group (user-scoped) +- add_committee_document: Add a Google Doc to track for a committee (leader only) +- list_committee_documents: List documents tracked by a committee +- update_committee_document: Update a tracked document (leader only) +- delete_committee_document: Remove a tracked document (leader only) **Member Profile:** - get_my_profile: Show user's profile From 90807dc6b933deb863dd5a874410ae85144f9a7f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Sun, 11 Jan 2026 20:54:48 -0500 Subject: [PATCH 40/77] refactor: align brand voice across homepage and docs (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: align brand voice across homepage and docs Updated messaging to emphasize allocation vs efficiency framing and core thesis: - Homepage: Added three pillars section with audience-specific taglines - Docs: Added opening hooks to protocol overviews (intro, media-buy, signals, creative) - Addie: Updated system prompt with new core thesis and framing for different audiences - Org homepage: Updated hero messaging Brand pillars: - Platforms: "AI is buying ads. Make sure it can buy yours." - Brands: "AI that sells products and builds brands." - Builders: "RTB unlocked programmatic. AdCP unlocks the rest." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * chore: add empty changeset for brand voice updates --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com> --- .changeset/good-kiwis-tie.md | 2 + docs/creative/index.mdx | 2 +- docs/intro.mdx | 28 +++-- docs/media-buy/index.mdx | 4 +- docs/signals/overview.mdx | 4 +- server/public/index.html | 217 +++++++++++++++++++---------------- server/public/org-index.html | 4 +- server/src/addie/prompts.ts | 9 +- 8 files changed, 157 insertions(+), 113 deletions(-) create mode 100644 .changeset/good-kiwis-tie.md 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/docs/creative/index.mdx b/docs/creative/index.mdx index d12c1fe9e3..8faf1958c6 100644 --- a/docs/creative/index.mdx +++ b/docs/creative/index.mdx @@ -3,7 +3,7 @@ title: Overview --- -This guide explains how creatives work in AdCP, from defining format requirements to assembling and delivering ads. +One upload, every format. This guide explains how creatives work in AdCP, from defining format requirements to assembling and delivering ads. ## The Four Key Concepts diff --git a/docs/intro.mdx b/docs/intro.mdx index 9e7d063f03..81e9e4f7a5 100644 --- a/docs/intro.mdx +++ b/docs/intro.mdx @@ -12,17 +12,23 @@ keywords: [advertising automation protocol, programmatic advertising API, MCP ad Developer experience and API refinement release featuring type safety improvements, batch creative previews (5-10x faster), schema versioning (`/schemas/v2.5/`), template formats with dynamic sizing, enhanced product filtering, and inline creative updates. [See what's new →](/docs/reference/release-notes) </Info> -Welcome to the Ad Context Protocol (AdCP) documentation. AdCP is an **open standard for advertising automation** that enables AI assistants to interact with advertising platforms through unified, standardized interfaces. +Welcome to the Ad Context Protocol (AdCP) documentation—the open standard for agentic advertising. -## What is Ad Context Protocol? +## The Opportunity -Ad Context Protocol (AdCP) is an **open standard for advertising automation** that enables AI-powered programmatic advertising workflows through: +RTB unlocked programmatic. AdCP unlocks the rest. -- **Unified Advertising API**: Single interface for all advertising platforms -- **AI-Powered Automation**: Works over MCP and A2A protocols for seamless AI integration -- **Multi-Protocol Support**: Access through MCP, A2A, or future transports +90% of ad spend never touches RTB—it lives in walled gardens, direct deals, and premium inventory. Execution costs limit how many media partners advertisers can work with. The opportunity isn't optimizing existing platforms better—it's expanding to more partners without scaling headcount. + +AI agents collapse this complexity cost. AdCP gives them a standard way to buy media, build creatives, and activate audiences across any platform. + +## What is AdCP? + +AdCP is an **open standard for advertising automation** that enables AI agents to interact with advertising platforms through unified interfaces: + +- **One Protocol**: Single interface for all advertising platforms +- **AI-Native**: Works over MCP and A2A protocols for seamless agent integration - **Platform Agnostic**: Works with any compatible advertising platform -- **Programmatic Advertising Made Simple**: Standardized workflows across all ad tech AdCP uses a task-first architecture where core advertising tasks (like creating media buys or discovering signals) can be accessed through multiple protocols: - **MCP (Model Context Protocol)**: For direct AI assistant integration @@ -232,13 +238,15 @@ Generate and optimize creative assets using AI-powered agents. ## For Platform Providers -If you operate a signal platform, DSP, or ad tech solution: +AI is buying ads. Make sure it can buy yours. -1. [Review the Protocol Specifications](/docs/signals/specification) +If you operate a signal platform, DSP, or ad tech solution, AdCP lets AI agents discover and purchase your inventory. [Review the Protocol Specifications](/docs/signals/specification) to get started. ## For Advertisers & Agencies -If you want to use AdCP with your AI assistant: +AI that sells products and builds brands. + +AdCP-enabled AI assistants can work across all your media partners through a single interface: 1. Check if your platforms support AdCP 2. Configure your AI assistant with AdCP-enabled platforms diff --git a/docs/media-buy/index.mdx b/docs/media-buy/index.mdx index 0ead41042a..6698517c56 100644 --- a/docs/media-buy/index.mdx +++ b/docs/media-buy/index.mdx @@ -5,7 +5,9 @@ keywords: [media buy protocol, advertising automation, AI advertising workflows, --- -The Media Buy protocol is AdCP's core advertising automation interface, providing 8 standardized tasks for managing the complete advertising lifecycle - from inventory discovery through campaign optimization. +AI is buying ads. The Media Buy protocol ensures it can buy yours. + +This is AdCP's core advertising automation interface, providing 8 standardized tasks for managing the complete advertising lifecycle—from inventory discovery through campaign optimization. ## Protocol Access diff --git a/docs/signals/overview.mdx b/docs/signals/overview.mdx index c6604448b7..f3388b18c0 100644 --- a/docs/signals/overview.mdx +++ b/docs/signals/overview.mdx @@ -4,7 +4,9 @@ title: Overview --- -The Signals Activation Protocol enables AI assistants to discover, activate, and manage data signals through natural language—making it practical to navigate and utilize the vast universe of available segments. +Signal providers have data. AI agents need access. The Signals Activation Protocol connects them. + +This protocol enables AI assistants to discover, activate, and manage data signals through natural language—making it practical to navigate and utilize the vast universe of available segments. ## The Challenge: Navigating Hundreds of Thousands of Segments diff --git a/server/public/index.html b/server/public/index.html index 1662374490..187c3a1a78 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -349,6 +349,66 @@ justify-content: center; } } + + /* Three Pillars Section */ + .pillarsSection_AdCP { + padding: 4rem 0; + background: white; + } + .pillarsGrid_AdCP { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; + } + .pillarCard_AdCP { + background: var(--ifm-color-emphasis-50, #f9fafb); + border-radius: 12px; + padding: 2rem; + text-align: center; + transition: all 0.2s ease; + border: 1px solid transparent; + } + .pillarCard_AdCP:hover { + border-color: var(--color-brand, #1a36b4); + box-shadow: 0 4px 20px rgba(26, 54, 180, 0.1); + } + .pillarIcon_AdCP { + font-size: 2.5rem; + margin-bottom: 1rem; + } + .pillarCard_AdCP h3 { + font-size: 1.25rem; + margin-bottom: 0.75rem; + color: var(--ifm-heading-color); + } + .pillarTagline_AdCP { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-brand, #1a36b4); + margin-bottom: 0.75rem; + line-height: 1.4; + } + .pillarCard_AdCP p { + font-size: 0.95rem; + color: var(--ifm-color-emphasis-700); + line-height: 1.6; + margin-bottom: 1rem; + } + .pillarLink_AdCP { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-brand, #1a36b4); + text-decoration: none; + } + .pillarLink_AdCP:hover { + text-decoration: underline; + } + @media (max-width: 900px) { + .pillarsGrid_AdCP { + grid-template-columns: 1fr; + gap: 1.5rem; + } + } </style> </head> @@ -368,12 +428,12 @@ <div class="container"> <div class="row"> <div class="col col--8 col--offset-2"> - <h1 class="heroTitle_qg2I">AdCP: The Open Standard for Agentic Advertising</h1> - <p class="heroSubtitle_jFu1">From brief to buy, helping agents advertise anywhere: from CTV to chat, from tiny blog to the World Cup.</p> + <h1 class="heroTitle_qg2I">The open standard for agentic advertising</h1> + <p class="heroSubtitle_jFu1">One protocol for AI agents to buy media, build creatives, and activate audiences across any platform.</p> <div class="buttons_AeoN"> - <a href="https://docs.adcontextprotocol.org" target="_blank" rel="noopener noreferrer" class="button button--primary button--lg margin-right--md">Start Building</a> - <a href="/chat.html?prompt=Try%20AdCP%20with%20a%20test%20agent" class="button button--secondary button--lg margin-right--md">Test with Addie</a> - <a href="https://github.com/adcontextprotocol/adcp" target="_blank" rel="noopener noreferrer" class="button button--outline button--secondary button--lg">View on GitHub</a> + <a href="https://docs.adcontextprotocol.org" target="_blank" rel="noopener noreferrer" class="button button--primary button--lg margin-right--md">Read the Docs</a> + <a href="/chat.html" class="button button--secondary button--lg margin-right--md">Talk to Addie</a> + <a href="https://github.com/adcontextprotocol/adcp" target="_blank" rel="noopener noreferrer" class="button button--outline button--secondary button--lg">GitHub</a> </div> </div> </div> @@ -381,62 +441,32 @@ <h1 class="heroTitle_qg2I">AdCP: The Open Standard for Agentic Advertising</h1> </header> <main> - <!-- Release Banner --> - <div class="releaseBanner_qgG2"> - <div class="container"> - <div class="releaseBannerContent_dZLA"> - <span class="releaseTag_YUts">v2.5.0 Released</span> - <span class="releaseText_vBj7">Developer experience and API refinement: type safety, batch previews (5-10x faster), schema versioning, and more!</span> - <a href="https://docs.adcontextprotocol.org/docs/reference/release-notes#version-250" target="_blank" rel="noopener noreferrer" class="releaseLink_cLn6">Read the release notes →</a> - </div> - </div> - </div> - - <!-- Go Agentic Section --> - <section class="goAgenticSection_AdCP"> + <!-- Three Pillars Section --> + <section class="pillarsSection_AdCP"> <div class="container"> <div class="row"> <div class="col col--10 col--offset-1"> - <div class="goAgenticCard_AdCP"> - <h3>Go Agentic</h3> - <p class="subtitle">Tell Addie what you're looking for. She'll help you find the right partners and resources.</p> - <div class="promptHints_AdCP"> - <a href="/chat?prompt=Help%20me%20build%20a%20buyer%20agent%20for%20my%20campaigns" class="promptHint_AdCP">Build a buyer agent for my campaigns</a> - <a href="/chat?prompt=Find%20someone%20to%20run%20a%20sales%20agent%20for%20my%20publisher%20inventory" class="promptHint_AdCP">Find a sales agent for my inventory</a> - <a href="/chat?prompt=Help%20me%20set%20up%20adagents.json%20for%20my%20site" class="promptHint_AdCP">Set up adagents.json for my site</a> - <a href="/chat?prompt=What%20can%20I%20do%20with%20AdCP%3F" class="promptHint_AdCP">What can I do with AdCP?</a> + <div class="pillarsGrid_AdCP"> + <div class="pillarCard_AdCP"> + <div class="pillarIcon_AdCP">📺</div> + <h3>For Platforms</h3> + <p class="pillarTagline_AdCP">AI is buying ads. Make sure it can buy yours.</p> + <p>Agents are allocating billions in media spend. AdCP makes your inventory accessible without commoditizing it.</p> + <a href="https://docs.adcontextprotocol.org/docs/media-buy/overview" class="pillarLink_AdCP">Implement AdCP →</a> </div> - <p class="goAgenticFooter_AdCP">Or <a href="/members">browse all members</a> to see what's possible</p> - </div> - </div> - </div> - </div> - </section> - - <!-- Problem Section --> - <section class="problemSection_YnFw"> - <div class="container"> - <div class="row"> - <div class="col col--10 col--offset-1"> - <div class="problemContent_hJvu"> - <h2 class="sectionTitle_Ut5p">Why we built AdCP</h2> - <p class="problemIntro_r3cU">The advertising ecosystem is fragmented. Every platform has its own API, its own workflow, its own reporting format. Media buyers and agencies waste countless hours navigating this complexity.</p> - <div class="problemGrid_Hidr"> - <div class="problemCard_DbFu"> - <h3>The Integration Problem</h3> - <p>Each new platform requires custom integration work. APIs change, documentation varies, and maintenance never ends. Teams spend more time on plumbing than on strategy.</p> - </div> - <div class="problemCard_DbFu"> - <h3>The Discovery Problem</h3> - <p>Inventory is scattered across platforms with different taxonomies and targeting options. Finding the right audiences means learning multiple systems and manually comparing options.</p> - </div> - <div class="problemCard_DbFu"> - <h3>The Automation Problem</h3> - <p>AI agents and automation tools can't easily interact with advertising platforms. Each integration is bespoke, limiting the potential of AI-powered workflows.</p> - </div> + <div class="pillarCard_AdCP"> + <div class="pillarIcon_AdCP">📈</div> + <h3>For Brands</h3> + <p class="pillarTagline_AdCP">AI that sells products and builds brands.</p> + <p>Not just impressions. AdCP enables automation that reasons about outcomes, audiences, and long-term value.</p> + <a href="/chat.html" class="pillarLink_AdCP">Talk to Addie →</a> </div> - <div class="visionStatement_hIxH"> - <p><strong>We believe there's a better way.</strong> A single protocol that any platform can implement and any tool can use. An open standard that makes advertising technology work together, not against each other.</p> + <div class="pillarCard_AdCP"> + <div class="pillarIcon_AdCP">🔧</div> + <h3>For Builders</h3> + <p class="pillarTagline_AdCP">RTB unlocked programmatic. AdCP unlocks the rest.</p> + <p>90% of ad spend never touches RTB. Build agents that access inventory walled gardens actually want to sell.</p> + <a href="https://docs.adcontextprotocol.org" class="pillarLink_AdCP">Start building →</a> </div> </div> </div> @@ -470,13 +500,24 @@ <h3>Managed by Agentic Advertising</h3> </div> </section> + <!-- Release Banner --> + <div class="releaseBanner_qgG2"> + <div class="container"> + <div class="releaseBannerContent_dZLA"> + <span class="releaseTag_YUts">v2.5.0</span> + <span class="releaseText_vBj7">Type safety, batch previews, schema versioning</span> + <a href="https://docs.adcontextprotocol.org/docs/reference/release-notes" target="_blank" rel="noopener noreferrer" class="releaseLink_cLn6">Release notes →</a> + </div> + </div> + </div> + <!-- Solution Section --> <section class="solutionSection_rMlo"> <div class="container"> <div class="row"> <div class="col col--10 col--offset-1"> - <h2 class="sectionTitle_Ut5p">One protocol. Every platform. Total control.</h2> - <p class="sectionSubtitle_AZuW">AdCP is the open standard that unifies advertising workflows across all platforms.<br>Think of it as the USB-C of advertising technology.</p> + <h2 class="sectionTitle_Ut5p">One protocol. Every platform.</h2> + <p class="sectionSubtitle_AZuW">Stop building custom integrations. Start building agents.</p> <div class="comparisonTable_c0Gw"> <div class="comparisonColumn_kYrh"> <h3>Before AdCP</h3> @@ -522,8 +563,8 @@ <h4>AdCP Workflow</h4> <div class="container"> <div class="row"> <div class="col col--10 col--offset-1"> - <h2 class="sectionTitle_Ut5p">Everything you need, production-ready</h2> - <p class="sectionSubtitle_AZuW">AdCP v2.5.0 includes a complete suite of capabilities for modern advertising workflows.</p> + <h2 class="sectionTitle_Ut5p">Three protocols. Complete coverage.</h2> + <p class="sectionSubtitle_AZuW">Media buying, creative generation, and audience activation.</p> <div class="featureGrid_hfN5"> <div class="featureCard_Jbd_"> <h3>🛒 <a href="https://docs.adcontextprotocol.org/docs/media-buy/overview" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">Media Buy Protocol</a></h3> @@ -576,8 +617,8 @@ <h3>⚡ Protocol Features</h3> <div class="container"> <div class="row"> <div class="col col--10 col--offset-1"> - <h2 class="sectionTitle_Ut5p">How AdCP works</h2> - <p class="sectionSubtitle_AZuW">AdCP works over MCP and A2A protocols, providing a unified interface for advertising operations across any platform.</p> + <h2 class="sectionTitle_Ut5p">How it works</h2> + <p class="sectionSubtitle_AZuW">Natural language in. Campaigns out.</p> <div class="workflowSteps_gwt9"> <div class="step_BHwS"> <div class="stepNumber_rxuX">1</div> @@ -622,33 +663,31 @@ <h3>Management</h3> <div class="container"> <div class="row"> <div class="col col--10 col--offset-1"> - <h2 class="sectionTitle_Ut5p">Ready to join the revolution?</h2> - <p class="sectionSubtitle_AZuW">Whether you're a platform provider or an advertiser, AdCP is your path to the future of advertising.</p> + <h2 class="sectionTitle_Ut5p">Get started</h2> + <p class="sectionSubtitle_AZuW">Open source. MIT licensed. Ready for production.</p> <div class="audienceCards_Jy9D"> <div class="audienceCard_fWZI"> - <h3>Platform Providers</h3> - <p class="audienceDescription_DkBw">Make your inventory accessible to every AI assistant and automation platform.</p> + <h3>Implement AdCP</h3> + <p class="audienceDescription_DkBw">Add AdCP to your platform or build an agent.</p> <ul class="audienceList_msXI"> - <li>Enable AI-powered workflows for your inventory</li> - <li>Simplify integration with a standard protocol</li> - <li>Reach new customers through automation platforms</li> + <li>Full protocol specification</li> + <li>Reference implementations</li> + <li>JSON Schema validation</li> </ul> <div class="audienceAction_gTR6"> - <a href="https://docs.adcontextprotocol.org" target="_blank" rel="noopener noreferrer" class="button button--outline button--secondary button--lg">Explore the Spec</a> - <p class="actionSubtext_sz5i">Open source • MIT licensed</p> + <a href="https://docs.adcontextprotocol.org" target="_blank" rel="noopener noreferrer" class="button button--outline button--secondary button--lg">Read the Docs</a> </div> </div> <div class="audienceCard_fWZI"> - <h3>Advertisers & Agencies</h3> - <p class="audienceDescription_DkBw">Start using natural language to manage campaigns across all platforms.</p> + <h3>Join the community</h3> + <p class="audienceDescription_DkBw">Shape the future of agentic advertising.</p> <ul class="audienceList_msXI"> - <li>Manage campaigns with natural language</li> - <li>Access unified analytics across platforms</li> - <li>Build on open standards, avoid vendor lock-in</li> + <li>Working groups and governance</li> + <li>Slack community</li> + <li>GitHub discussions</li> </ul> <div class="audienceAction_gTR6"> - <a href="https://docs.adcontextprotocol.org" target="_blank" rel="noopener noreferrer" class="button button--primary button--lg">Start Building Today</a> - <p class="actionSubtext_sz5i">Documentation & guides</p> + <a href="/membership" class="button button--primary button--lg">Become a Member</a> </div> </div> </div> @@ -662,25 +701,11 @@ <h3>Advertisers & Agencies</h3> <div class="container"> <div class="row"> <div class="col col--8 col--offset-2 text--center"> - <h2 class="sectionTitle_Ut5p">Join the conversation</h2> - <p class="communityDescription_YESH">AdCP is an open standard developed in collaboration with the advertising community. We're building this together, and your input matters.</p> - <div class="communityFeatures_N643"> - <div class="communityFeature_n6x3"> - <h3>Open Development</h3> - <p>All development happens in the open on GitHub. Watch progress, submit issues, and contribute code.</p> - </div> - <div class="communityFeature_n6x3"> - <h3>Working Group</h3> - <p>Join monthly meetings to discuss protocol evolution, implementation challenges, and future directions.</p> - </div> - <div class="communityFeature_n6x3"> - <h3>Implementation Support</h3> - <p>Get help implementing AdCP for your platform or building tools that use the protocol.</p> - </div> - </div> + <h2 class="sectionTitle_Ut5p">Built in the open</h2> + <p class="communityDescription_YESH">AdCP is developed collaboratively on GitHub. Star the repo, join the Slack, shape the standard.</p> <div class="communityActions_BVMn"> - <a href="https://github.com/adcontextprotocol/adcp" target="_blank" rel="noopener noreferrer" class="button button--primary button--lg margin-right--md">Star on GitHub</a> - <a href="https://join.slack.com/t/agenticads/shared_invite/zt-3h15gj6c0-FRTrD_y4HqmeXDKBl2TDEA" target="_blank" rel="noopener noreferrer" class="button button--outline button--secondary button--lg">Join Slack Community</a> + <a href="https://github.com/adcontextprotocol/adcp" target="_blank" rel="noopener noreferrer" class="button button--primary button--lg margin-right--md">GitHub</a> + <a href="https://join.slack.com/t/agenticads/shared_invite/zt-3h15gj6c0-FRTrD_y4HqmeXDKBl2TDEA" target="_blank" rel="noopener noreferrer" class="button button--outline button--secondary button--lg">Slack</a> </div> </div> </div> diff --git a/server/public/org-index.html b/server/public/org-index.html index a9cdf86b6b..24185f5c0f 100644 --- a/server/public/org-index.html +++ b/server/public/org-index.html @@ -425,9 +425,9 @@ <div class="hero-logo"> <img src="/AAo.svg" alt="AgenticAdvertising.org" class="aao-logo"> </div> - <h1>Advancing AI-Powered Advertising Together</h1> + <h1>The industry organization for agentic advertising</h1> <p class="hero-subtitle"> - AgenticAdvertising.org is the independent industry organization building open standards and best practices for agentic advertising. + We steward open standards like AdCP and bring together platforms, brands, agencies, and builders shaping the future of AI-powered advertising. </p> <div class="hero-buttons"> <a href="/membership" class="btn btn-primary">Become a Founding Member</a> diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts index 9f973cb8c8..9fda4a4d79 100644 --- a/server/src/addie/prompts.ts +++ b/server/src/addie/prompts.ts @@ -11,9 +11,14 @@ const logger = createLogger('addie-prompts'); export const ADDIE_SYSTEM_PROMPT = `You are Addie, the AI assistant for AgenticAdvertising.org. Your mission is to help advertisers allocate budgets across more media partners than ever before - and help publishers capture that new spend. -**The Core Thesis:** Today, advertisers work with only 3-5 platforms because execution costs are brutal. The opportunity isn't optimizing those platforms better - it's expanding to 20+ partners without tripling your team. AI agents collapse this complexity cost. This is a $1-2 trillion allocation problem, not an efficiency problem. +**The Core Thesis:** Execution costs limit how many media partners advertisers can work with. The opportunity isn't optimizing existing platforms better - it's expanding to more partners without scaling headcount. AI agents collapse this complexity cost. This is an allocation problem, not an efficiency problem. -**The Analogy:** "OpenRTB is a protocol for day trading; AdCP is a protocol for investing." RTB reasons about impressions in real-time. AdCP reasons about budgets, relationships, and outcomes over time. +**The Key Framing:** "RTB unlocked programmatic. AdCP unlocks the rest." 90% of ad spend never touches RTB - it lives in walled gardens, direct deals, and premium inventory. AdCP gives agents access to inventory that platforms actually want to sell. + +**For different audiences, emphasize:** +- **Platforms:** "AI is buying ads. Make sure it can buy yours." - Help them see that agents are allocating spend, and AdCP makes their inventory accessible without commoditizing it. +- **Brands:** "AI that sells products and builds brands." - Not just impressions. AdCP enables automation that reasons about outcomes, audiences, and long-term value. +- **Builders:** "RTB unlocked programmatic. AdCP unlocks the rest." - Build agents that access inventory walled gardens actually want to sell. ## PRIORITY: Account Setup From 269f000d7ed171078755ce09a997f220702d0def Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 08:56:13 -0500 Subject: [PATCH 41/77] fix: broken working group post links (#720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /perspectives/:slug route redirects to /latest/research, which was causing all working group post links to be broken. Updated to use /working-groups/:slug#post-:postSlug instead. - Slack notifications now link to the correct working group page - Post links in detail.html now use anchor fragments - Post view links in manage.html now use anchor fragments - Updated misleading form hint text 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/fix-perspectives-link.md | 4 ++++ server/public/working-groups/detail.html | 4 ++-- server/public/working-groups/manage.html | 4 ++-- server/src/notifications/slack.ts | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-perspectives-link.md 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/server/public/working-groups/detail.html b/server/public/working-groups/detail.html index c9df4ab91e..61d59cfc7f 100644 --- a/server/public/working-groups/detail.html +++ b/server/public/working-groups/detail.html @@ -1029,7 +1029,7 @@ <h2 id="modalTitle">New Post</h2> <div class="form-group"> <label>Slug</label> <input type="text" id="postSlug" required placeholder="url-friendly-slug" pattern="[a-z0-9-]+"> - <small>URL path: /perspectives/{slug}</small> + <small>Post identifier for linking</small> </div> <div class="form-group"> <label>Category</label> @@ -1290,7 +1290,7 @@ <h4>${escapeHtml(displayName)}</h4> } postsList.innerHTML = posts.map(post => ` - <a href="/perspectives/${escapeHtml(post.slug)}" class="post-card"> + <a href="/working-groups/${currentSlug}#post-${escapeHtml(post.slug)}" class="post-card"> <div class="post-title">${escapeHtml(post.title)}</div> <div class="post-meta"> <span>${formatDate(post.published_at)}</span> diff --git a/server/public/working-groups/manage.html b/server/public/working-groups/manage.html index 69a49f9459..74f6dfc17b 100644 --- a/server/public/working-groups/manage.html +++ b/server/public/working-groups/manage.html @@ -625,7 +625,7 @@ <h2 id="modalTitle">New Post</h2> <div class="form-group"> <label>Slug</label> <input type="text" id="slug" required placeholder="url-friendly-slug" pattern="[a-z0-9-]+"> - <small>URL path: /perspectives/{slug}</small> + <small>Post identifier for linking</small> </div> <div class="form-group"> <label>Category</label> @@ -1079,7 +1079,7 @@ <h3>${escapeHtml(post.title)}</h3> </div> </div> <div class="post-actions"> - ${post.status === 'published' ? `<a href="/perspectives/${post.slug}" target="_blank" class="btn btn-secondary btn-small">View</a>` : ''} + ${post.status === 'published' ? `<a href="/working-groups/${currentSlug}#post-${post.slug}" target="_blank" class="btn btn-secondary btn-small">View</a>` : ''} <button class="btn btn-primary btn-small" onclick="editPost('${post.id}')">Edit</button> <button class="btn btn-danger btn-small" onclick="showDeleteModal('${post.id}', '${escapeHtml(post.title).replace(/'/g, "\\'")}')">Delete</button> </div> diff --git a/server/src/notifications/slack.ts b/server/src/notifications/slack.ts index d4b90cb64d..81cc79b88c 100644 --- a/server/src/notifications/slack.ts +++ b/server/src/notifications/slack.ts @@ -343,7 +343,7 @@ export async function notifyWorkingGroupPost(data: { }): Promise<boolean> { const emoji = data.contentType === 'link' ? '🔗' : '📝'; const typeLabel = data.contentType === 'link' ? 'Link' : 'Article'; - const postUrl = `https://agenticadvertising.org/perspectives/${data.postSlug}`; + const postUrl = `https://agenticadvertising.org/working-groups/${data.workingGroupSlug}#post-${data.postSlug}`; const groupUrl = `https://agenticadvertising.org/working-groups/${data.workingGroupSlug}`; return sendSlackMessage({ From db8002b98bdeefee2ad8fbcccd2e3eca9b917ce5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 10:14:11 -0500 Subject: [PATCH 42/77] feat: add membership metrics to admin analytics dashboard (#722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new endpoints and UI tables showing membership counts broken down by company type and revenue tier. Includes: - GET /api/admin/membership-metrics - JSON endpoint with metrics - GET /api/admin/membership-metrics/csv - CSV export - UI tables in admin analytics page with totals Security fixes: - HTML escaping in frontend to prevent XSS - CSV value escaping to prevent CSV injection Performance: - Run all queries in parallel with Promise.all - Shared SQL filter constants to reduce duplication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/famous-sides-hide.md | 7 + server/public/admin-analytics.html | 199 +++++++++++++++++++++++++ server/src/routes/admin/stats.ts | 227 +++++++++++++++++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 .changeset/famous-sides-hide.md 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/server/public/admin-analytics.html b/server/public/admin-analytics.html index 2ec15fc90f..bac2d8457c 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; + } </style> </head> <body> @@ -273,6 +303,63 @@ <h2>Revenue by Product</h2> <tbody></tbody> </table> </div> + + <!-- Membership Metrics by Company Type --> + <div class="section"> + <h2>Membership by Company Type</h2> + <div class="section-actions"> + <button class="btn btn-secondary" onclick="downloadMembershipCSV()">Export CSV</button> + </div> + <table id="company-type-table"> + <thead> + <tr> + <th>Company Type</th> + <th>Total Orgs</th> + <th>Paying</th> + <th>Subscribed</th> + <th>ARR</th> + </tr> + </thead> + <tbody></tbody> + </table> + </div> + + <!-- Membership Metrics by Revenue Tier --> + <div class="section"> + <h2>Membership by Revenue Tier</h2> + <table id="revenue-tier-table"> + <thead> + <tr> + <th>Revenue Tier</th> + <th>Total Orgs</th> + <th>Paying</th> + <th>Subscribed</th> + <th>ARR</th> + </tr> + </thead> + <tbody></tbody> + </table> + </div> + + <!-- Full Matrix --> + <div class="section"> + <h2>Full Breakdown (Company Type × Revenue Tier)</h2> + <div class="table-container"> + <table id="matrix-table"> + <thead> + <tr> + <th>Company Type</th> + <th>Revenue Tier</th> + <th>Total Orgs</th> + <th>Paying</th> + <th>Subscribed</th> + <th>ARR</th> + </tr> + </thead> + <tbody></tbody> + </table> + </div> + </div> </div> </div> @@ -399,8 +486,120 @@ <h2>Revenue by Product</h2> } } + // 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 => ` + <tr> + <td>${escapeHtml(row.label)}</td> + <td class="text-right">${row.total_orgs}</td> + <td class="text-right">${row.paying_members}</td> + <td class="text-right">${row.subscribed}</td> + <td class="text-right">${formatCurrency(row.arr_dollars)}</td> + </tr> + `).join(''); + + // Add totals row (excluding individuals which are separate) + const totals = data.by_company_type.reduce((acc, row) => ({ + total_orgs: acc.total_orgs + row.total_orgs, + paying_members: acc.paying_members + row.paying_members, + subscribed: acc.subscribed + row.subscribed, + arr_dollars: acc.arr_dollars + row.arr_dollars, + }), { total_orgs: 0, paying_members: 0, subscribed: 0, arr_dollars: 0 }); + + companyTypeTable.innerHTML = rows + ` + <tr class="totals-row"> + <td>Subtotal (Companies)</td> + <td class="text-right">${totals.total_orgs}</td> + <td class="text-right">${totals.paying_members}</td> + <td class="text-right">${totals.subscribed}</td> + <td class="text-right">${formatCurrency(totals.arr_dollars)}</td> + </tr> + <tr> + <td>Individuals</td> + <td class="text-right">${data.individuals.total_orgs}</td> + <td class="text-right">${data.individuals.paying_members}</td> + <td class="text-right">${data.individuals.subscribed}</td> + <td class="text-right">${formatCurrency(data.individuals.arr_dollars)}</td> + </tr> + <tr class="totals-row"> + <td><strong>Total</strong></td> + <td class="text-right"><strong>${data.totals.total_orgs}</strong></td> + <td class="text-right"><strong>${data.totals.paying_members}</strong></td> + <td class="text-right"><strong>${data.totals.subscribed}</strong></td> + <td class="text-right"><strong>${formatCurrency(data.totals.arr_dollars)}</strong></td> + </tr> + `; + } else { + companyTypeTable.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--color-text-secondary);">No data available</td></tr>'; + } + + // 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 => ` + <tr> + <td>${escapeHtml(row.label)}</td> + <td class="text-right">${row.total_orgs}</td> + <td class="text-right">${row.paying_members}</td> + <td class="text-right">${row.subscribed}</td> + <td class="text-right">${formatCurrency(row.arr_dollars)}</td> + </tr> + `).join(''); + } else { + revenueTierTable.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--color-text-secondary);">No data available</td></tr>'; + } + + // Populate matrix table + const matrixTable = document.getElementById('matrix-table').querySelector('tbody'); + if (data.matrix && data.matrix.length > 0) { + matrixTable.innerHTML = data.matrix.map(row => ` + <tr> + <td>${escapeHtml(row.company_type_label)}</td> + <td>${escapeHtml(row.revenue_tier_label)}</td> + <td class="text-right">${row.total_orgs}</td> + <td class="text-right">${row.paying_members}</td> + <td class="text-right">${row.subscribed}</td> + <td class="text-right">${formatCurrency(row.arr_dollars)}</td> + </tr> + `).join(''); + } else { + matrixTable.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--color-text-secondary);">No data available</td></tr>'; + } + } catch (error) { + console.error('Error loading membership metrics:', error); + // Show error state in all membership tables + const errorHtml = '<tr><td colspan="5" style="text-align: center; color: var(--color-error-500);">Error loading data</td></tr>'; + const matrixErrorHtml = '<tr><td colspan="6" style="text-align: center; color: var(--color-error-500);">Error loading data</td></tr>'; + 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(); </script> </body> </html> diff --git a/server/src/routes/admin/stats.ts b/server/src/routes/admin/stats.ts index bf57b4eb7d..25b1c12fc8 100644 --- a/server/src/routes/admin/stats.ts +++ b/server/src/routes/admin/stats.ts @@ -596,4 +596,231 @@ export function setupStatsRoutes(apiRouter: Router): void { }); } }); + + // Shared label maps for membership metrics + const COMPANY_TYPE_LABELS: Record<string, string> = { + adtech: 'Ad Tech', + agency: 'Agency', + brand: 'Brand', + publisher: 'Publisher', + data: 'Data & Measurement', + ai: 'AI & Tech Platforms', + other: 'Other', + unknown: 'Unknown', + }; + + const REVENUE_TIER_LABELS: Record<string, string> = { + under_1m: '<$1M', + '1m_5m': '$1M-$5M', + '5m_50m': '$5M-$50M', + '50m_250m': '$50M-$250M', + '250m_1b': '$250M-$1B', + '1b_plus': '$1B+', + unknown: 'Unknown', + }; + + // Shared SQL fragments for membership metrics + const PAYING_FILTER = `subscription_status = 'active' AND subscription_canceled_at IS NULL AND subscription_amount > 0`; + const SUBSCRIBED_FILTER = `subscription_status IN ('active', 'past_due', 'trialing')`; + + // GET /api/admin/membership-metrics - Get membership metrics by company_type × revenue_tier + // Returns current snapshot using existing category dimensions + apiRouter.get("/membership-metrics", requireAuth, requireAdmin, async (req, res) => { + try { + const pool = getPool(); + + // Run all queries in parallel for better performance + const [byTypeResult, byTierResult, matrixResult, individualsResult, totalsResult] = await Promise.all([ + // Get metrics by company_type + pool.query(` + SELECT + COALESCE(company_type, 'unknown') AS company_type, + COUNT(*) AS total_orgs, + COUNT(*) FILTER (WHERE ${PAYING_FILTER}) AS paying_members, + COUNT(*) FILTER (WHERE ${SUBSCRIBED_FILTER}) AS subscribed, + COALESCE(SUM(subscription_amount) FILTER (WHERE ${PAYING_FILTER}), 0) AS arr_cents + FROM organizations + WHERE is_personal IS NOT TRUE + GROUP BY company_type + ORDER BY + CASE company_type + WHEN 'adtech' THEN 1 + WHEN 'agency' THEN 2 + WHEN 'brand' THEN 3 + WHEN 'publisher' THEN 4 + WHEN 'data' THEN 5 + WHEN 'ai' THEN 6 + WHEN 'other' THEN 7 + ELSE 8 + END + `), + + // Get metrics by revenue_tier + pool.query(` + SELECT + COALESCE(revenue_tier, 'unknown') AS revenue_tier, + COUNT(*) AS total_orgs, + COUNT(*) FILTER (WHERE ${PAYING_FILTER}) AS paying_members, + COUNT(*) FILTER (WHERE ${SUBSCRIBED_FILTER}) AS subscribed, + COALESCE(SUM(subscription_amount) FILTER (WHERE ${PAYING_FILTER}), 0) AS arr_cents + FROM organizations + WHERE is_personal IS NOT TRUE + GROUP BY revenue_tier + ORDER BY + CASE revenue_tier + WHEN 'under_1m' THEN 1 + WHEN '1m_5m' THEN 2 + WHEN '5m_50m' THEN 3 + WHEN '50m_250m' THEN 4 + WHEN '250m_1b' THEN 5 + WHEN '1b_plus' THEN 6 + ELSE 7 + END + `), + + // Get the full matrix: company_type × revenue_tier + pool.query(` + SELECT + COALESCE(company_type, 'unknown') AS company_type, + COALESCE(revenue_tier, 'unknown') AS revenue_tier, + COUNT(*) AS total_orgs, + COUNT(*) FILTER (WHERE ${PAYING_FILTER}) AS paying_members, + COUNT(*) FILTER (WHERE ${SUBSCRIBED_FILTER}) AS subscribed, + COALESCE(SUM(subscription_amount) FILTER (WHERE ${PAYING_FILTER}), 0) AS arr_cents + FROM organizations + WHERE is_personal IS NOT TRUE + GROUP BY company_type, revenue_tier + ORDER BY company_type, revenue_tier + `), + + // Get individuals (personal workspaces) separately + pool.query(` + SELECT + COUNT(*) AS total_orgs, + COUNT(*) FILTER (WHERE ${PAYING_FILTER}) AS paying_members, + COUNT(*) FILTER (WHERE ${SUBSCRIBED_FILTER}) AS subscribed, + COALESCE(SUM(subscription_amount) FILTER (WHERE ${PAYING_FILTER}), 0) AS arr_cents + FROM organizations + WHERE is_personal = TRUE + `), + + // Get totals + pool.query(` + SELECT + COUNT(*) AS total_orgs, + COUNT(*) FILTER (WHERE ${PAYING_FILTER}) AS paying_members, + COUNT(*) FILTER (WHERE ${SUBSCRIBED_FILTER}) AS subscribed, + COALESCE(SUM(subscription_amount) FILTER (WHERE ${PAYING_FILTER}), 0) AS arr_cents + FROM organizations + `), + ]); + + const formatRow = (row: { total_orgs: string; paying_members: string; subscribed: string; arr_cents: string }) => ({ + total_orgs: parseInt(row.total_orgs) || 0, + paying_members: parseInt(row.paying_members) || 0, + subscribed: parseInt(row.subscribed) || 0, + arr_cents: parseInt(row.arr_cents) || 0, + arr_dollars: Math.round((parseInt(row.arr_cents) || 0) / 100), + }); + + res.json({ + by_company_type: byTypeResult.rows.map(row => ({ + company_type: row.company_type, + label: COMPANY_TYPE_LABELS[row.company_type] || row.company_type, + ...formatRow(row), + })), + by_revenue_tier: byTierResult.rows.map(row => ({ + revenue_tier: row.revenue_tier, + label: REVENUE_TIER_LABELS[row.revenue_tier] || row.revenue_tier, + ...formatRow(row), + })), + matrix: matrixResult.rows.map(row => ({ + company_type: row.company_type, + company_type_label: COMPANY_TYPE_LABELS[row.company_type] || row.company_type, + revenue_tier: row.revenue_tier, + revenue_tier_label: REVENUE_TIER_LABELS[row.revenue_tier] || row.revenue_tier, + ...formatRow(row), + })), + individuals: formatRow(individualsResult.rows[0] || { total_orgs: '0', paying_members: '0', subscribed: '0', arr_cents: '0' }), + totals: formatRow(totalsResult.rows[0] || { total_orgs: '0', paying_members: '0', subscribed: '0', arr_cents: '0' }), + labels: { + company_types: COMPANY_TYPE_LABELS, + revenue_tiers: REVENUE_TIER_LABELS, + }, + }); + } catch (error) { + logger.error({ err: error }, "Error fetching membership metrics"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch membership metrics", + }); + } + }); + + // Escape CSV values to prevent CSV injection and handle special characters + function escapeCsvValue(value: string | number): string { + const str = String(value); + // If value contains comma, quote, newline, or starts with formula chars, escape it + if (/[,"\n\r]/.test(str) || /^[=+\-@\t\r]/.test(str)) { + // Wrap in quotes and escape any quotes, prefix formula chars with single quote + const escaped = str.replace(/"/g, '""'); + const prefixed = /^[=+\-@\t\r]/.test(escaped) ? `'${escaped}` : escaped; + return `"${prefixed}"`; + } + return str; + } + + // GET /api/admin/membership-metrics/csv - Export membership metrics as CSV + apiRouter.get("/membership-metrics/csv", requireAuth, requireAdmin, async (req, res) => { + try { + const pool = getPool(); + + // Get the full matrix for CSV export + const result = await pool.query(` + SELECT + COALESCE(company_type, 'unknown') AS company_type, + COALESCE(revenue_tier, 'unknown') AS revenue_tier, + COUNT(*) AS total_orgs, + COUNT(*) FILTER (WHERE ${PAYING_FILTER}) AS paying_members, + COUNT(*) FILTER (WHERE ${SUBSCRIBED_FILTER}) AS subscribed, + ROUND(COALESCE(SUM(subscription_amount) FILTER (WHERE ${PAYING_FILTER}), 0) / 100.0, 2) AS arr_dollars + FROM organizations + WHERE is_personal IS NOT TRUE + GROUP BY company_type, revenue_tier + ORDER BY + CASE company_type + WHEN 'adtech' THEN 1 WHEN 'agency' THEN 2 WHEN 'brand' THEN 3 + WHEN 'publisher' THEN 4 WHEN 'data' THEN 5 WHEN 'ai' THEN 6 + WHEN 'other' THEN 7 ELSE 8 + END, + CASE revenue_tier + WHEN 'under_1m' THEN 1 WHEN '1m_5m' THEN 2 WHEN '5m_50m' THEN 3 + WHEN '50m_250m' THEN 4 WHEN '250m_1b' THEN 5 WHEN '1b_plus' THEN 6 + ELSE 7 + END + `); + + const headers = ['Company Type', 'Revenue Tier', 'Total Orgs', 'Paying Members', 'Subscribed', 'ARR ($)']; + const rows = result.rows.map(row => [ + escapeCsvValue(COMPANY_TYPE_LABELS[row.company_type] || row.company_type), + escapeCsvValue(REVENUE_TIER_LABELS[row.revenue_tier] || row.revenue_tier), + row.total_orgs, + row.paying_members, + row.subscribed, + row.arr_dollars || 0, + ].join(',')); + + const csv = [headers.join(','), ...rows].join('\n'); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="membership-metrics-${new Date().toISOString().split('T')[0]}.csv"`); + res.send(csv); + } catch (error) { + logger.error({ err: error }, "Error exporting membership metrics CSV"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to export membership metrics", + }); + } + }); } From 32842ebb921816522a346afac933491a9e3f4b1f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 10:14:42 -0500 Subject: [PATCH 43/77] feat: add bulk prospect creation from domain discovery (#721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds multiselect functionality to the Domain Discovery tab on the prospects screen, allowing admins to select multiple domains and create prospects in bulk. Frontend changes: - Add checkbox column to domain discovery table - Track selected domains with Set - "Create X Prospects" button appears when domains selected - Select all checkbox syncs with individual selections - Disable checkboxes during bulk creation Backend changes: - POST /api/admin/domains/bulk-create-prospects endpoint - Validates domain format and source - Normalizes domains (lowercase, strip www.) - Max 50 domains per request - Sanitizes error messages to avoid exposing internals - Auto-enriches new organizations in background 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/dirty-meteors-sell.md | 2 + server/public/admin-prospects.html | 152 +++++++++++++++++++++-- server/src/routes/admin/domains.ts | 186 +++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 .changeset/dirty-meteors-sell.md 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/server/public/admin-prospects.html b/server/public/admin-prospects.html index 7f002b34d7..94aa32d19d 100644 --- a/server/public/admin-prospects.html +++ b/server/public/admin-prospects.html @@ -1417,6 +1417,8 @@ <h2>Bulk Import Prospects</h2> // Hide standard filters, show domain source filter document.querySelector('.filters').style.display = 'none'; updateTableHeaders(true); + // Clear selection when loading fresh data + selectedDomains.clear(); try { // Load both Slack and Email domains in parallel @@ -1561,12 +1563,16 @@ <h3>Error loading domain data</h3> renderDomainDiscovery(); } + // Track selected domains for bulk creation + let selectedDomains = new Set(); + function updateTableHeaders(viewType) { const header = document.getElementById('tableHeader'); if (viewType === true || viewType === 'domain_discovery') { // Domain discovery view header.innerHTML = ` <tr> + <th style="width: 40px;"><input type="checkbox" id="selectAllDomains" onchange="toggleSelectAllDomains(this.checked)" title="Select all new prospects"></th> <th>Domain</th> <th>Source</th> <th>Users</th> @@ -1622,10 +1628,11 @@ <h3>Error loading domain data</h3> const emailCount = discoveredDomains.filter(d => d.sources.includes('email') && d.is_new_prospect).length; const unenrichedCount = discoveredDomains.filter(d => d.is_new_prospect && !enrichmentCache[d.domain]).length; + const selectedCount = selectedDomains.size; countDiv.innerHTML = ` <div style="display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap;"> - <span>Showing ${domainsToShow.length} domains (${newProspects.length} new prospects)</span> + <span>Showing ${domainsToShow.length} domains (${newProspects.length} new prospects)${selectedCount > 0 ? ` \u2022 ${selectedCount} selected` : ''}</span> <div style="display: flex; gap: var(--space-2);"> <button class="source-filter-btn ${currentDomainSource === 'all' ? 'active' : ''}" data-source="all" onclick="filterDomainsBySource('all')"> All @@ -1637,18 +1644,25 @@ <h3>Error loading domain data</h3> Email (${emailCount}) </button> </div> - ${enrichmentConfigured && unenrichedCount > 0 ? ` - <button class="btn btn-secondary btn-sm" onclick="enrichAllNewDomains()" style="margin-left: auto;"> - Research All (${Math.min(unenrichedCount, 25)}) - </button> - ` : ''} + <div style="display: flex; gap: var(--space-2); margin-left: auto;"> + ${selectedCount > 0 ? ` + <button class="btn btn-primary btn-sm" onclick="createSelectedProspects()" id="createSelectedBtn"> + Create ${selectedCount} Prospect${selectedCount > 1 ? 's' : ''} + </button> + ` : ''} + ${enrichmentConfigured && unenrichedCount > 0 ? ` + <button class="btn btn-secondary btn-sm" onclick="enrichAllNewDomains()"> + Research All (${Math.min(unenrichedCount, 25)}) + </button> + ` : ''} + </div> </div> `; if (domainsToShow.length === 0) { tbody.innerHTML = ` <tr> - <td colspan="6" class="empty-state"> + <td colspan="7" class="empty-state"> <h3>No unmapped domains found</h3> <p>No contacts from this source need organization mapping.</p> </td> @@ -1716,8 +1730,14 @@ <h3>No unmapped domains found</h3> actionHtml = `<a href="/admin/organizations/${domain.existing_org.id}" class="btn btn-secondary btn-sm">View Org</a>`; } + // Checkbox for new prospects + const checkboxHtml = domain.is_new_prospect + ? `<input type="checkbox" class="domain-checkbox" data-domain="${domain.domain}" data-source="${primarySource}" ${selectedDomains.has(domain.domain) ? 'checked' : ''} onchange="toggleDomainSelection('${domain.domain}', '${primarySource}', this.checked)">` + : ''; + return ` <tr${domain.is_new_prospect ? '' : ' style="opacity: 0.6;"'}> + <td>${checkboxHtml}</td> <td><strong>${domain.domain}</strong></td> <td>${sourceBadges}</td> <td><span title="${domain.total_users} total users">${domain.total_users}</span></td> @@ -1727,6 +1747,13 @@ <h3>No unmapped domains found</h3> </tr> `; }).join(''); + + // Sync "select all" checkbox state + const selectAllCheckbox = document.getElementById('selectAllDomains'); + if (selectAllCheckbox && newProspects.length > 0) { + const allSelected = newProspects.every(d => selectedDomains.has(d.domain)); + selectAllCheckbox.checked = allSelected; + } } async function enrichDomain(domain) { @@ -1848,6 +1875,117 @@ <h3>No unmapped domains found</h3> } } + function toggleDomainSelection(domain, source, checked) { + if (checked) { + selectedDomains.add(domain); + } else { + selectedDomains.delete(domain); + } + // Re-render to update button and selection count + renderDomainDiscovery(); + } + + function toggleSelectAllDomains(checked) { + // Get currently visible new prospects based on filter + let domainsToShow = discoveredDomains; + if (currentDomainSource === 'slack') { + domainsToShow = discoveredDomains.filter(d => d.sources.includes('slack')); + } else if (currentDomainSource === 'email') { + domainsToShow = discoveredDomains.filter(d => d.sources.includes('email')); + } + + const newProspects = domainsToShow.filter(d => d.is_new_prospect); + + if (checked) { + newProspects.forEach(d => selectedDomains.add(d.domain)); + } else { + newProspects.forEach(d => selectedDomains.delete(d.domain)); + } + + renderDomainDiscovery(); + } + + async function createSelectedProspects() { + if (selectedDomains.size === 0) { + alert('No domains selected'); + return; + } + + const count = selectedDomains.size; + if (!confirm(`Create ${count} prospect${count > 1 ? 's' : ''} from the selected domains?`)) { + return; + } + + const btn = document.getElementById('createSelectedBtn'); + if (btn) { + btn.disabled = true; + btn.textContent = 'Creating...'; + } + + // Disable all checkboxes during creation + document.querySelectorAll('.domain-checkbox, #selectAllDomains').forEach(cb => cb.disabled = true); + + try { + // Build the domains array with source info + const domainsToCreate = []; + for (const domain of selectedDomains) { + const domainInfo = discoveredDomains.find(d => d.domain === domain); + if (domainInfo) { + const primarySource = domainInfo.sources.includes('slack') ? 'slack' : 'email'; + domainsToCreate.push({ domain, source: primarySource }); + } + } + + const response = await fetch('/api/admin/domains/bulk-create-prospects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domains: domainsToCreate }) + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminNav.redirectToLogin(); + return; + } + const error = await response.json(); + throw new Error(error.message || 'Failed to create prospects'); + } + + const result = await response.json(); + + // Clear selection + selectedDomains.clear(); + + // Show results + if (result.failed > 0) { + const failedDomains = result.results + .filter(r => !r.success) + .map(r => `${r.domain}: ${r.error}`) + .join('\n'); + alert(`Created ${result.successful} of ${result.total} prospects.\n\nFailed:\n${failedDomains}`); + } else { + alert(`Successfully created ${result.successful} prospect${result.successful > 1 ? 's' : ''}`); + } + + // Refresh domain discovery view + await loadDomainDiscovery(); + // Also refresh view counts + const countsRes = await fetch('/api/admin/prospects/view-counts'); + if (countsRes.ok) { + viewCounts = await countsRes.json(); + updateViewCounts(); + } + } catch (error) { + alert('Error: ' + error.message); + // Re-enable checkboxes and button on error + document.querySelectorAll('.domain-checkbox, #selectAllDomains').forEach(cb => cb.disabled = false); + if (btn) { + btn.disabled = false; + btn.textContent = `Create ${selectedDomains.size} Prospect${selectedDomains.size > 1 ? 's' : ''}`; + } + } + } + // ========================================================================= // PROSPECT SEARCH FUNCTIONS // ========================================================================= diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 647200a640..8a75250cc5 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -282,6 +282,192 @@ export function setupDomainRoutes( } ); + // POST /api/admin/domains/bulk-create-prospects - Create multiple prospects at once + apiRouter.post( + "/domains/bulk-create-prospects", + requireAuth, + requireAdmin, + async (req, res) => { + try { + const { domains } = req.body; + + if (!Array.isArray(domains) || domains.length === 0) { + return res.status(400).json({ error: "domains array is required" }); + } + + if (domains.length > 50) { + return res.status(400).json({ error: "Maximum 50 domains per request" }); + } + + if (!workos) { + return res.status(500).json({ error: "WorkOS not configured" }); + } + + const pool = getPool(); + const results: Array<{ + domain: string; + success: boolean; + org_id?: string; + name?: string; + error?: string; + }> = []; + + for (const item of domains) { + const { domain, source } = item; + + if (!domain || typeof domain !== 'string') { + results.push({ domain: domain || 'unknown', success: false, error: 'Invalid domain' }); + continue; + } + + // Validate source + if (!source || (source !== 'email' && source !== 'slack')) { + results.push({ domain, success: false, error: 'Invalid source: must be "email" or "slack"' }); + continue; + } + + // Normalize and validate domain format + const normalizedDomain = domain.toLowerCase().trim().replace(/^www\./, ''); + const domainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z]{2,})+$/; + if (!domainRegex.test(normalizedDomain)) { + results.push({ domain, success: false, error: 'Invalid domain format' }); + continue; + } + + try { + // Check if domain already has an organization + const existingResult = await pool.query( + `SELECT od.workos_organization_id, o.name + FROM organization_domains od + JOIN organizations o ON o.workos_organization_id = od.workos_organization_id + WHERE od.domain = $1`, + [normalizedDomain] + ); + + if (existingResult.rows.length > 0) { + results.push({ + domain: normalizedDomain, + success: false, + error: `Domain already belongs to ${existingResult.rows[0].name}`, + }); + continue; + } + + // Get context based on source + let contextUsers: string[] = []; + let contextCount = 0; + + if (source === 'email') { + const contactsResult = await pool.query( + `SELECT email, display_name, email_count + FROM email_contacts + WHERE mapping_status = 'unmapped' + AND LOWER(domain) = LOWER($1) + ORDER BY email_count DESC + LIMIT 10`, + [normalizedDomain] + ); + contextCount = contactsResult.rows.length; + contextUsers = contactsResult.rows + .map((c) => c.display_name || c.email.split("@")[0]) + .filter(Boolean) + .slice(0, 5); + } else { + // Slack source + const domainData = await slackDb.getUnmappedDomains({ + excludeFreeEmailProviders: false, + minUsers: 1, + }); + const domainInfo = domainData.find( + (d) => d.domain.toLowerCase() === normalizedDomain + ); + if (domainInfo) { + contextCount = domainInfo.user_count; + contextUsers = domainInfo.users + .map((u) => u.slack_real_name || u.slack_display_name) + .filter((name): name is string => Boolean(name)) + .slice(0, 5); + } + } + + // Generate org name from domain + const orgName = normalizedDomain + .split(".") + .slice(0, -1) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + + // Create organization in WorkOS with the domain + const workosOrg = await workos.organizations.createOrganization({ + name: orgName, + domainData: [{ domain: normalizedDomain, state: DomainDataState.Verified }], + }); + + // Create local record + const sourceType = source === 'email' ? 'email_discovery' : 'slack_discovery'; + const sourceLabel = source === 'email' ? 'email contacts' : 'Slack'; + const notes = `Discovered via ${sourceLabel}. ${contextCount} contact(s): ${contextUsers.join(", ")}`; + + await pool.query( + `INSERT INTO organizations ( + workos_organization_id, + name, + prospect_status, + prospect_source, + prospect_notes + ) VALUES ($1, $2, $3, $4, $5)`, + [workosOrg.id, orgName, "prospect", sourceType, notes] + ); + + // Auto-enrich in background + enrichOrganization(workosOrg.id, normalizedDomain).catch((err) => { + logger.warn({ err, domain: normalizedDomain, orgId: workosOrg.id }, "Background enrichment failed"); + }); + + logger.info( + { orgId: workosOrg.id, name: orgName, domain: normalizedDomain, source }, + "Created prospect from bulk domain creation" + ); + + results.push({ + domain: normalizedDomain, + success: true, + org_id: workosOrg.id, + name: orgName, + }); + } catch (err) { + logger.error({ err, domain: normalizedDomain }, "Error creating prospect in bulk operation"); + // Sanitize error message to avoid exposing internal details + const userMessage = err instanceof Error && err.message.toLowerCase().includes('domain') + ? 'Domain is already claimed or invalid' + : 'Failed to create organization'; + results.push({ + domain: normalizedDomain, + success: false, + error: userMessage, + }); + } + } + + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + res.status(201).json({ + total: domains.length, + successful, + failed, + results, + }); + } catch (error) { + logger.error({ err: error }, "Error in bulk prospect creation"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to create prospects", + }); + } + } + ); + // ========================================================================= // EMAIL CONTACT DOMAIN DISCOVERY // ========================================================================= From 2b79d1981c7f11e2a96295cfb8c51eeaa36b0b1c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 10:36:33 -0500 Subject: [PATCH 44/77] feat: track pending Slack users on account detail pages (#723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: track pending Slack users on account detail pages Add ability to track and display Slack users who have email domains matching existing organizations but haven't formally joined yet. This provides visibility into potential members discovered via Slack domain discovery. Changes: - Add pending_organization_id column to slack_user_mappings table - Add methods to link Slack users by domain and get pending user counts - Display pending Slack users on account detail page - Add backfill tool to domain health admin page - Include pending Slack counts in prospect list API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: exclude already-linked users from domain discovery Users with pending_organization_id set should not appear in the domain discovery list since they're already associated with a prospect. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/some-phones-study.md | 2 + server/public/admin-account-detail.html | 37 ++++ server/public/admin-domain-health.html | 149 +++++++++++++ .../160_slack_pending_organization.sql | 19 ++ server/src/db/slack-db.ts | 206 ++++++++++++++++++ server/src/routes/admin/accounts.ts | 24 ++ server/src/routes/admin/domains.ts | 65 ++++++ server/src/routes/admin/prospects.ts | 23 +- server/src/slack/types.ts | 2 + 9 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 .changeset/some-phones-study.md create mode 100644 server/src/db/migrations/160_slack_pending_organization.sql 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/server/public/admin-account-detail.html b/server/public/admin-account-detail.html index 212a8a1d86..cee9811a8d 100644 --- a/server/public/admin-account-detail.html +++ b/server/public/admin-account-detail.html @@ -823,6 +823,22 @@ <h2 class="collapsible collapsed" onclick="toggleSection(this)" style="margin-bo </div> </div> + <!-- Pending Slack Users (only shown if there are any) --> + <div class="card" id="pendingSlackCard" style="display: none;"> + <h2 class="collapsible" onclick="toggleSection(this)" style="margin-bottom: 0;"> + Pending Slack Users + <span id="pendingSlackCount" style="font-weight: normal; color: var(--color-text-muted);"></span> + </h2> + <div class="collapsible-content" id="pendingSlackContent" style="margin-top: var(--space-4);"> + <p style="font-size: var(--text-sm); color: var(--color-text-muted); margin-bottom: var(--space-3);"> + These users have email addresses matching this account's domain but haven't signed up yet. + </p> + <ul id="pendingSlackList" class="members-list"> + <li class="empty-state">No pending users</li> + </ul> + </div> + </div> + <!-- Working Groups --> <div class="card"> <h2 class="collapsible collapsed" onclick="toggleSection(this)">Working Groups</h2> @@ -1533,6 +1549,27 @@ <h2 id="userContextModalTitle">Member Context</h2> document.getElementById('membersList').innerHTML = membersHtml; } + // Pending Slack users (discovered via domain but not yet members) + if (a.pending_slack_users && a.pending_slack_users.length > 0) { + document.getElementById('pendingSlackCard').style.display = 'block'; + document.getElementById('pendingSlackCount').textContent = `(${a.pending_slack_users.length})`; + let pendingHtml = ''; + for (const u of a.pending_slack_users) { + const name = u.slack_real_name || u.slack_display_name || u.slack_email || 'Unknown'; + const lastActive = u.last_slack_activity_at + ? new Date(u.last_slack_activity_at).toLocaleDateString() + : 'Never'; + pendingHtml += `<li> + <span style="font-weight: 500;">${escapeHtml(name)}</span> + <div style="color: var(--color-text-muted); font-size: var(--text-xs);">${escapeHtml(u.slack_email || 'No email')}</div> + <div style="color: var(--color-text-muted); font-size: var(--text-xs);">Last active: ${lastActive}</div> + </li>`; + } + document.getElementById('pendingSlackList').innerHTML = pendingHtml; + } else { + document.getElementById('pendingSlackCard').style.display = 'none'; + } + // Working groups if (a.working_groups && a.working_groups.length > 0) { let wgHtml = ''; diff --git a/server/public/admin-domain-health.html b/server/public/admin-domain-health.html index 1f8baa0dea..c05dea2038 100644 --- a/server/public/admin-domain-health.html +++ b/server/public/admin-domain-health.html @@ -274,6 +274,22 @@ <h1>Domain Health</h1> <div class="summary-grid" id="summaryGrid"> <!-- Populated by JS --> </div> + + <!-- Maintenance Actions --> + <div style="margin-top: var(--space-5); padding-top: var(--space-5); border-top: 1px solid var(--color-border);"> + <div style="display: flex; align-items: center; gap: var(--space-4);"> + <div style="flex: 1;"> + <div style="font-weight: var(--font-semibold); color: var(--color-text-heading);"> + Backfill Slack User Links + <span id="backfillCount" style="margin-left: var(--space-2); padding: var(--space-0.5) var(--space-2); background: var(--color-gray-100); color: var(--color-text-secondary); border-radius: var(--radius-full); font-size: var(--text-xs); font-weight: var(--font-normal);">loading...</span> + </div> + <div style="font-size: var(--text-sm); color: var(--color-text-secondary);">Link unmapped Slack users to existing organizations based on their email domain</div> + </div> + <button class="btn btn-secondary" id="backfillSlackBtn" disabled>Run Backfill</button> + </div> + <div id="backfillPreview" style="margin-top: var(--space-3); display: none;"></div> + <div id="backfillResult" style="margin-top: var(--space-3); display: none;"></div> + </div> </div> <!-- Orphan Domains Section --> @@ -1654,8 +1670,141 @@ <h3 style="color: var(--color-success-600); margin-bottom: var(--space-4);">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 = ` + <div style="padding: var(--space-3); background: var(--color-gray-50); border: 1px solid var(--color-border); border-radius: var(--radius-md);"> + <div style="font-size: var(--text-sm); color: var(--color-text-secondary); margin-bottom: var(--space-2);">Users to be linked:</div> + <ul style="margin: 0; padding-left: var(--space-4); font-size: var(--text-sm); color: var(--color-text-secondary);"> + ${status.byOrganization.slice(0, 5).map(o => `<li><strong>${escapeHtml(o.organizationName)}</strong> (${escapeHtml(o.domain)}): ${o.userCount} users</li>`).join('')} + ${status.byOrganization.length > 5 ? `<li>...and ${status.byOrganization.length - 5} more organizations</li>` : ''} + </ul> + </div> + `; + 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 = ` + <div style="padding: var(--space-3); background: var(--color-success-50); border: 1px solid var(--color-success-200); border-radius: var(--radius-md);"> + <div style="font-weight: var(--font-semibold); color: var(--color-success-700);"> + Linked ${result.usersLinked} Slack users to ${result.details.length} organizations + </div> + <ul style="margin: var(--space-2) 0 0 var(--space-4); font-size: var(--text-sm); color: var(--color-text-secondary);"> + ${result.details.slice(0, 5).map(d => `<li>${escapeHtml(d.organizationName)} (${d.domain}): ${d.usersLinked} users</li>`).join('')} + ${result.details.length > 5 ? `<li>...and ${result.details.length - 5} more</li>` : ''} + </ul> + </div> + `; + } else { + resultDiv.innerHTML = ` + <div style="padding: var(--space-3); background: var(--color-gray-50); border: 1px solid var(--color-border); border-radius: var(--radius-md);"> + <div style="color: var(--color-text-secondary);">No unmapped Slack users found to link. All users are already associated with their organizations.</div> + </div> + `; + } + 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 = ` + <div style="padding: var(--space-3); background: var(--color-error-50); border: 1px solid var(--color-error-200); border-radius: var(--radius-md);"> + <div style="color: var(--color-error-700);">Error: ${escapeHtml(error.message)}</div> + </div> + `; + resultDiv.style.display = 'block'; + + setTimeout(() => { + btn.disabled = false; + btn.classList.remove('btn-error'); + btn.classList.add('btn-secondary'); + btn.textContent = originalText; + }, 3000); + } + } + // Initialize + document.getElementById('backfillSlackBtn').addEventListener('click', runSlackBackfill); loadDomainHealth(); + loadBackfillStatus(); </script> </body> </html> diff --git a/server/src/db/migrations/160_slack_pending_organization.sql b/server/src/db/migrations/160_slack_pending_organization.sql new file mode 100644 index 0000000000..7fc2c9a930 --- /dev/null +++ b/server/src/db/migrations/160_slack_pending_organization.sql @@ -0,0 +1,19 @@ +-- Migration: 160_slack_pending_organization.sql +-- Add pending_organization_id to track which prospect a Slack user is associated with +-- before they've fully joined (e.g., discovered via domain discovery) + +ALTER TABLE slack_user_mappings +ADD COLUMN IF NOT EXISTS pending_organization_id VARCHAR(255); + +-- Index for querying pending users by organization +CREATE INDEX IF NOT EXISTS idx_slack_mapping_pending_org + ON slack_user_mappings(pending_organization_id) + WHERE pending_organization_id IS NOT NULL AND mapping_status = 'unmapped'; + +-- Index for efficient email domain lookups when linking users +CREATE INDEX IF NOT EXISTS idx_slack_mapping_email_domain + ON slack_user_mappings(LOWER(SPLIT_PART(slack_email, '@', 2))) + WHERE mapping_status = 'unmapped' AND slack_is_bot = false AND slack_is_deleted = false; + +COMMENT ON COLUMN slack_user_mappings.pending_organization_id IS + 'Organization ID that this unmapped user is associated with (e.g., via domain discovery). Used to show pending user counts on prospects before users formally join.'; diff --git a/server/src/db/slack-db.ts b/server/src/db/slack-db.ts index 350b156fb7..e3c0878d28 100644 --- a/server/src/db/slack-db.ts +++ b/server/src/db/slack-db.ts @@ -374,6 +374,7 @@ export class SlackDatabase { } // First, get the domains with counts + // Exclude users already linked to a pending organization (already have a prospect) const domainQuery = ` SELECT LOWER(SPLIT_PART(slack_email, '@', 2)) as domain, @@ -384,6 +385,7 @@ export class SlackDatabase { AND slack_is_deleted = false AND slack_email IS NOT NULL AND slack_email LIKE '%@%' + AND pending_organization_id IS NULL ${domainExcludeClause} GROUP BY LOWER(SPLIT_PART(slack_email, '@', 2)) HAVING COUNT(*) >= $${excludeFree ? freeEmailDomains.length + 1 : 1} @@ -425,6 +427,7 @@ export class SlackDatabase { WHERE mapping_status = 'unmapped' AND slack_is_bot = false AND slack_is_deleted = false + AND pending_organization_id IS NULL AND LOWER(SPLIT_PART(slack_email, '@', 2)) = $1 ORDER BY slack_real_name NULLS LAST, slack_display_name NULLS LAST`, [row.domain] @@ -716,4 +719,207 @@ export class SlackDatabase { return new Set(result.rows.map(row => row.workos_user_id)); } + /** + * Link unmapped Slack users with a specific email domain to an organization. + * Sets the pending_organization_id field so they're associated with the prospect. + * + * @param domain The email domain to match (e.g., "publicis.com") + * @param organizationId The WorkOS organization ID to link users to + * @returns Number of users linked and their details + */ + async linkSlackUsersByDomain( + domain: string, + organizationId: string + ): Promise<{ + usersLinked: number; + users: Array<{ + slack_user_id: string; + slack_email: string; + slack_real_name: string | null; + }>; + }> { + const normalizedDomain = domain.toLowerCase(); + + const updateResult = await query<{ + slack_user_id: string; + slack_email: string; + slack_real_name: string | null; + }>( + `UPDATE slack_user_mappings + SET pending_organization_id = $1, + updated_at = NOW() + WHERE mapping_status = 'unmapped' + AND slack_is_bot = false + AND slack_is_deleted = false + AND LOWER(SPLIT_PART(slack_email, '@', 2)) = $2 + RETURNING slack_user_id, slack_email, slack_real_name`, + [organizationId, normalizedDomain] + ); + + return { + usersLinked: updateResult.rows.length, + users: updateResult.rows, + }; + } + + /** + * Get count of pending (unmapped) Slack users for an organization by domain + */ + async getPendingSlackUserCount(organizationId: string): Promise<number> { + const result = await query<{ count: string }>( + `SELECT COUNT(*) as count + FROM slack_user_mappings + WHERE pending_organization_id = $1 + AND mapping_status = 'unmapped' + AND slack_is_bot = false + AND slack_is_deleted = false`, + [organizationId] + ); + return parseInt(result.rows[0]?.count || '0', 10); + } + + /** + * Get pending (unmapped) Slack users for an organization + */ + async getPendingSlackUsers(organizationId: string): Promise<Array<{ + slack_user_id: string; + slack_email: string; + slack_real_name: string | null; + slack_display_name: string | null; + }>> { + const result = await query<{ + slack_user_id: string; + slack_email: string; + slack_real_name: string | null; + slack_display_name: string | null; + }>( + `SELECT slack_user_id, slack_email, slack_real_name, slack_display_name + FROM slack_user_mappings + WHERE pending_organization_id = $1 + AND mapping_status = 'unmapped' + AND slack_is_bot = false + AND slack_is_deleted = false + ORDER BY slack_real_name NULLS LAST, slack_display_name NULLS LAST`, + [organizationId] + ); + return result.rows; + } + + /** + * Get count of unmapped Slack users that could be linked to existing organizations. + * These are users whose email domain matches an organization but don't have pending_organization_id set. + */ + async getBackfillableUserCount(): Promise<{ + totalUsers: number; + byOrganization: Array<{ + organizationId: string; + organizationName: string; + domain: string; + userCount: number; + }>; + }> { + const result = await query<{ + workos_organization_id: string; + name: string; + domain: string; + user_count: string; + }>( + `SELECT + o.workos_organization_id, + o.name, + COALESCE(od.domain, o.email_domain) as domain, + COUNT(sm.id) as user_count + FROM organizations o + LEFT JOIN organization_domains od ON od.workos_organization_id = o.workos_organization_id + JOIN slack_user_mappings sm ON + LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(COALESCE(od.domain, o.email_domain)) + AND sm.mapping_status = 'unmapped' + AND sm.slack_is_bot = false + AND sm.slack_is_deleted = false + AND sm.pending_organization_id IS NULL + WHERE o.is_personal = false + AND (od.domain IS NOT NULL OR o.email_domain IS NOT NULL) + GROUP BY o.workos_organization_id, o.name, COALESCE(od.domain, o.email_domain) + ORDER BY COUNT(sm.id) DESC` + ); + + const byOrganization = result.rows.map((r) => ({ + organizationId: r.workos_organization_id, + organizationName: r.name, + domain: r.domain, + userCount: parseInt(r.user_count, 10), + })); + + return { + totalUsers: byOrganization.reduce((sum, o) => sum + o.userCount, 0), + byOrganization, + }; + } + + /** + * Backfill pending_organization_id for existing prospects. + * Links unmapped Slack users to organizations based on their email domain + * matching organization_domains or organizations.email_domain. + * + * @returns Summary of organizations and users linked + */ + async backfillPendingOrganizations(): Promise<{ + organizationsProcessed: number; + usersLinked: number; + details: Array<{ + organizationId: string; + organizationName: string; + domain: string; + usersLinked: number; + }>; + }> { + // Find all orgs with domains that have unmapped Slack users + const orgsWithDomainsResult = await query<{ + workos_organization_id: string; + name: string; + domain: string; + }>( + `SELECT DISTINCT o.workos_organization_id, o.name, COALESCE(od.domain, o.email_domain) as domain + FROM organizations o + LEFT JOIN organization_domains od ON od.workos_organization_id = o.workos_organization_id + WHERE o.is_personal = false + AND (od.domain IS NOT NULL OR o.email_domain IS NOT NULL) + AND EXISTS ( + SELECT 1 FROM slack_user_mappings sm + WHERE sm.mapping_status = 'unmapped' + AND sm.slack_is_bot = false + AND sm.slack_is_deleted = false + AND sm.pending_organization_id IS NULL + AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(COALESCE(od.domain, o.email_domain)) + )` + ); + + const details: Array<{ + organizationId: string; + organizationName: string; + domain: string; + usersLinked: number; + }> = []; + let totalUsersLinked = 0; + + for (const org of orgsWithDomainsResult.rows) { + const linkResult = await this.linkSlackUsersByDomain(org.domain, org.workos_organization_id); + if (linkResult.usersLinked > 0) { + details.push({ + organizationId: org.workos_organization_id, + organizationName: org.name, + domain: org.domain, + usersLinked: linkResult.usersLinked, + }); + totalUsersLinked += linkResult.usersLinked; + } + } + + return { + organizationsProcessed: orgsWithDomainsResult.rows.length, + usersLinked: totalUsersLinked, + details, + }; + } + } diff --git a/server/src/routes/admin/accounts.ts b/server/src/routes/admin/accounts.ts index e9526d46f2..d71ae5ef71 100644 --- a/server/src/routes/admin/accounts.ts +++ b/server/src/routes/admin/accounts.ts @@ -317,6 +317,7 @@ export function setupAccountRoutes( membersResult, misalignedUsersResult, similarOrgsResult, + pendingSlackUsersResult, ] = await Promise.all([ // Working groups pool.query( @@ -535,6 +536,25 @@ export function setupAccountRoutes( `, [orgId] ), + + // Pending Slack users (discovered via domain but not yet members) + pool.query( + ` + SELECT + slack_user_id, + slack_email, + slack_display_name, + slack_real_name, + last_slack_activity_at + FROM slack_user_mappings + WHERE pending_organization_id = $1 + AND mapping_status = 'unmapped' + AND slack_is_bot = false + AND slack_is_deleted = false + ORDER BY last_slack_activity_at DESC NULLS LAST, slack_real_name ASC + `, + [orgId] + ), ]); // Get engagement signals @@ -684,6 +704,10 @@ export function setupAccountRoutes( ), }, + // Pending Slack users (discovered via domain but not yet linked/members) + pending_slack_users: pendingSlackUsersResult.rows, + pending_slack_count: pendingSlackUsersResult.rows.length, + owner: owner ? { user_id: owner.user_id, diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 8a75250cc5..bf55562823 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -255,10 +255,20 @@ export function setupDomainRoutes( logger.warn({ err, domain, orgId: workosOrg.id }, "Background enrichment failed"); }); + // Link the unmapped Slack users to this new prospect + const linkResult = await slackDb.linkSlackUsersByDomain(domain, workosOrg.id); + if (linkResult.usersLinked > 0) { + logger.info( + { orgId: workosOrg.id, domain, usersLinked: linkResult.usersLinked }, + "Linked Slack users to new prospect" + ); + } + res.status(201).json({ ...result.rows[0], domain, slack_users: domainInfo.users, + slack_users_linked: linkResult.usersLinked, workos_org: { id: workosOrg.id, domains: workosOrg.domains, @@ -1862,4 +1872,59 @@ export function setupDomainRoutes( } } ); + + // ========================================================================= + // BACKFILL TOOLS + // ========================================================================= + + // GET /api/admin/slack/backfill-status - Get count of users that can be backfilled + apiRouter.get( + "/slack/backfill-status", + requireAuth, + requireAdmin, + async (_req, res) => { + try { + const status = await slackDb.getBackfillableUserCount(); + res.json(status); + } catch (error) { + logger.error({ err: error }, "Error getting backfill status"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to get backfill status", + }); + } + } + ); + + // POST /api/admin/slack/backfill-pending-orgs - Backfill pending_organization_id for existing prospects + apiRouter.post( + "/slack/backfill-pending-orgs", + requireAuth, + requireAdmin, + async (_req, res) => { + try { + const result = await slackDb.backfillPendingOrganizations(); + + logger.info( + { + organizationsProcessed: result.organizationsProcessed, + usersLinked: result.usersLinked, + }, + "Completed Slack pending organization backfill" + ); + + res.json({ + success: true, + message: `Linked ${result.usersLinked} Slack users to ${result.details.length} organizations`, + ...result, + }); + } catch (error) { + logger.error({ err: error }, "Error running Slack pending org backfill"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to run backfill", + }); + } + } + ); } diff --git a/server/src/routes/admin/prospects.ts b/server/src/routes/admin/prospects.ts index bf72e45094..4d288dec72 100644 --- a/server/src/routes/admin/prospects.ts +++ b/server/src/routes/admin/prospects.ts @@ -253,6 +253,7 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon recentActivityCounts, stakeholdersResult, slackUserCounts, + pendingSlackCounts, domainsResult, lastActivitiesResult, pendingStepsResult, @@ -284,7 +285,7 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon CASE role WHEN 'owner' THEN 1 WHEN 'interested' THEN 2 WHEN 'connected' THEN 3 END `, [orgIds]), - // Slack user counts + // Slack user counts (mapped users who are org members) pool.query(` SELECT om.workos_organization_id, COUNT(DISTINCT sm.slack_user_id) as slack_user_count FROM slack_user_mappings sm @@ -294,6 +295,17 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon GROUP BY om.workos_organization_id `, [orgIds]), + // Pending Slack user counts (unmapped users linked to org via domain discovery) + pool.query(` + SELECT pending_organization_id, COUNT(*) as pending_slack_count + FROM slack_user_mappings + WHERE pending_organization_id = ANY($1) + AND mapping_status = 'unmapped' + AND slack_is_bot = false + AND slack_is_deleted = false + GROUP BY pending_organization_id + `, [orgIds]), + // Domains pool.query(` SELECT workos_organization_id, domain, is_primary, verified @@ -382,6 +394,10 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon slackUserCounts.rows.map((r) => [r.workos_organization_id, parseInt(r.slack_user_count)]) ); + const pendingSlackCountMap = new Map( + pendingSlackCounts.rows.map((r) => [r.pending_organization_id, parseInt(r.pending_slack_count)]) + ); + const domainsMap = new Map<string, Array<{ domain: string; is_primary: boolean; verified: boolean }>>(); for (const row of domainsResult.rows) { if (!domainsMap.has(row.workos_organization_id)) { @@ -449,6 +465,7 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon const recentActivityCount = activityCountMap.get(row.workos_organization_id) || 0; const pendingInvoices = pendingInvoicesMap.get(row.workos_organization_id) || []; const slackUserCount = slackUserCountMap.get(row.workos_organization_id) || 0; + const pendingSlackCount = pendingSlackCountMap.get(row.workos_organization_id) || 0; // Use stored engagement_level directly (matches detail page calculation) const engagementScore = row.engagement_score || 0; @@ -464,6 +481,9 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon if (slackUserCount > 0) { engagementReasons.push(`${slackUserCount} Slack user(s)`); } + if (pendingSlackCount > 0) { + engagementReasons.push(`${pendingSlackCount} pending Slack user(s)`); + } if (memberCount > 0) { engagementReasons.push(`${memberCount} team member(s)`); } @@ -498,6 +518,7 @@ export function setupProspectRoutes(apiRouter: Router, config: ProspectRoutesCon engagement_reasons: engagementReasons, stakeholders: stakeholdersMap.get(row.workos_organization_id) || [], slack_user_count: slackUserCount, + pending_slack_count: pendingSlackCount, domains: domainsMap.get(row.workos_organization_id) || [], last_activity: lastActivityMap.get(row.workos_organization_id) || null, pending_steps: pendingStepsMap.get(row.workos_organization_id) || { pending: 0, overdue: 0 }, diff --git a/server/src/slack/types.ts b/server/src/slack/types.ts index 21adec0a45..30027cf3d4 100644 --- a/server/src/slack/types.ts +++ b/server/src/slack/types.ts @@ -32,6 +32,8 @@ export interface SlackUserMapping { outreach_opt_out_at: Date | null; // Timezone offset in seconds from UTC (from Slack) slack_tz_offset: number | null; + // Organization ID for unmapped users discovered via domain (from migration 160) + pending_organization_id: string | null; created_at: Date; updated_at: Date; } From 7464d6f4e7da470a347f5fe895c573bb84a55114 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 11:16:45 -0500 Subject: [PATCH 45/77] feat: add proactive outreach to job scheduler (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scheduled proactive outreach to the job scheduler so Addie can automatically send DMs to eligible users during business hours. - Runs every 30 minutes, sends up to 5 messages per run - Initial 2-minute delay on startup to allow server initialization - Respects existing safety controls: business hours, rate limiting, opt-outs, and OUTREACH_ENABLED kill switch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/hip-bugs-jam.md | 2 ++ server/src/addie/jobs/scheduler.ts | 51 ++++++++++++++++++++++++++++++ server/src/http.ts | 3 ++ 3 files changed, 56 insertions(+) create mode 100644 .changeset/hip-bugs-jam.md 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/server/src/addie/jobs/scheduler.ts b/server/src/addie/jobs/scheduler.ts index 226196a5e6..72429f8128 100644 --- a/server/src/addie/jobs/scheduler.ts +++ b/server/src/addie/jobs/scheduler.ts @@ -8,6 +8,7 @@ import { logger as baseLogger } from '../../logger.js'; import { runDocumentIndexerJob } from './committee-document-indexer.js'; import { runSummaryGeneratorJob } from './committee-summary-generator.js'; +import { runOutreachScheduler } from '../services/proactive-outreach.js'; const logger = baseLogger.child({ module: 'job-scheduler' }); @@ -66,6 +67,49 @@ class JobScheduler { logger.debug({ intervalMinutes: INTERVAL_MINUTES }, 'Document indexer job started'); } + /** + * Start the proactive outreach job + * Sends DMs to eligible users during business hours + */ + startOutreach(): void { + const JOB_NAME = 'proactive-outreach'; + const INTERVAL_MINUTES = 30; // Check every 30 minutes + const INITIAL_DELAY_MS = 120000; // 2 minute delay on startup + + const job: ScheduledJob = { + name: JOB_NAME, + intervalId: null, + initialTimeoutId: null, + }; + + // Run after a delay on startup + job.initialTimeoutId = setTimeout(async () => { + try { + const result = await runOutreachScheduler({ limit: 5 }); + if (result.sent > 0) { + logger.info(result, 'Proactive outreach: initial run completed'); + } + } catch (err) { + logger.error({ err }, 'Proactive outreach: initial run failed'); + } + }, INITIAL_DELAY_MS); + + // Then run periodically + job.intervalId = setInterval(async () => { + try { + const result = await runOutreachScheduler({ limit: 5 }); + if (result.sent > 0) { + logger.info(result, 'Proactive outreach: job completed'); + } + } catch (err) { + logger.error({ err }, 'Proactive outreach: job failed'); + } + }, INTERVAL_MINUTES * 60 * 1000); + + this.jobs.set(JOB_NAME, job); + logger.debug({ intervalMinutes: INTERVAL_MINUTES }, 'Proactive outreach job started'); + } + /** * Start the committee summary generator job * Generates AI-powered activity summaries for committees @@ -116,6 +160,13 @@ class JobScheduler { this.stopJob('document-indexer'); } + /** + * Stop the proactive outreach job + */ + stopOutreach(): void { + this.stopJob('proactive-outreach'); + } + /** * Stop the summary generator job */ diff --git a/server/src/http.ts b/server/src/http.ts index 487731c0ef..d5cb108c26 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -6822,6 +6822,9 @@ Disallow: /api/admin/ jobScheduler.startDocumentIndexer(); jobScheduler.startSummaryGenerator(); + // Start proactive outreach job + jobScheduler.startOutreach(); + this.server = this.app.listen(port, () => { logger.info({ port, From f9ecbc338df2d0580d3d06de0aed8c25ed277c8f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 11:17:01 -0500 Subject: [PATCH 46/77] fix: link Slack users in bulk prospect creation (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk-create-prospects endpoint was missing the linkSlackUsersByDomain call that the single-prospect creation endpoint has. This caused Slack users to not be linked to their pending organization when prospects were created in bulk from domain discovery. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/strict-lamps-turn.md | 4 ++++ server/src/routes/admin/domains.ts | 11 +++++++++++ 2 files changed, 15 insertions(+) create mode 100644 .changeset/strict-lamps-turn.md 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/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index bf55562823..1b813996ca 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -434,6 +434,17 @@ export function setupDomainRoutes( logger.warn({ err, domain: normalizedDomain, orgId: workosOrg.id }, "Background enrichment failed"); }); + // Link unmapped Slack users to this new prospect (same as single-create) + if (source === 'slack') { + const linkResult = await slackDb.linkSlackUsersByDomain(normalizedDomain, workosOrg.id); + if (linkResult.usersLinked > 0) { + logger.info( + { orgId: workosOrg.id, domain: normalizedDomain, usersLinked: linkResult.usersLinked }, + "Linked Slack users to new prospect from bulk creation" + ); + } + } + logger.info( { orgId: workosOrg.id, name: orgName, domain: normalizedDomain, source }, "Created prospect from bulk domain creation" From 5d530b0201d36ffe85481de77fabbd7a54203b25 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 11:33:42 -0500 Subject: [PATCH 47/77] fix: reduce log noise and fix SQL/Slackbot issues (#726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change Stripe getBillingProducts logging from info to debug level - Fix SQL error in getWorkingGroupsNeedingSummaryRefresh by removing unnecessary DISTINCT (ORDER BY column must be in SELECT list) - Filter out USLACKBOT in canContactUser to prevent planner from processing Slack system bot messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> --- .changeset/afraid-hairs-add.md | 2 ++ server/src/addie/services/proactive-outreach.ts | 11 +++++++++++ server/src/billing/stripe-client.ts | 15 ++------------- server/src/db/working-group-db.ts | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 .changeset/afraid-hairs-add.md 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/server/src/addie/services/proactive-outreach.ts b/server/src/addie/services/proactive-outreach.ts index 271d0cda19..80319d55b2 100644 --- a/server/src/addie/services/proactive-outreach.ts +++ b/server/src/addie/services/proactive-outreach.ts @@ -856,6 +856,12 @@ export function getOutreachMode(): 'live' { return OUTREACH_MODE; } +/** + * Slack's built-in system bot user ID. + * Slackbot sends system notifications that should always be ignored. + */ +const SLACKBOT_USER_ID = 'USLACKBOT'; + /** * Check if a specific user can be contacted */ @@ -863,6 +869,11 @@ export async function canContactUser(slackUserId: string): Promise<{ canContact: boolean; reason?: string; }> { + // Always reject Slackbot - it's a system bot, not a real user + if (slackUserId === SLACKBOT_USER_ID) { + return { canContact: false, reason: 'Slackbot is a system bot' }; + } + const result = await query<SlackUserMapping>( `SELECT * FROM slack_user_mappings WHERE slack_user_id = $1`, [slackUserId] diff --git a/server/src/billing/stripe-client.ts b/server/src/billing/stripe-client.ts index 6b8e44f029..aed0c756ab 100644 --- a/server/src/billing/stripe-client.ts +++ b/server/src/billing/stripe-client.ts @@ -102,7 +102,7 @@ export async function getBillingProducts(): Promise<BillingProduct[]> { limit: 100, }); - logger.info({ totalPrices: prices.data.length }, 'getBillingProducts: Fetched prices from Stripe'); + logger.debug({ totalPrices: prices.data.length }, 'getBillingProducts: Fetched prices from Stripe'); const products: BillingProduct[] = []; @@ -182,18 +182,7 @@ export async function getBillingProducts(): Promise<BillingProduct[]> { productsCache = products; productsCacheTime = Date.now(); - // Log the products found for debugging - const productSummary = products.map(p => ({ - lookup_key: p.lookup_key, - display_name: p.display_name, - amount_cents: p.amount_cents, - category: p.category, - billing_type: p.billing_type, - })); - logger.info({ - count: products.length, - products: productSummary, - }, 'getBillingProducts: Fetched billing products from Stripe'); + logger.debug({ count: products.length }, 'getBillingProducts: Cache refreshed from Stripe'); return products; } catch (error) { logger.error({ err: error }, 'getBillingProducts: Error fetching billing products'); diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index e94df5c44e..86ebd6a910 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -1654,7 +1654,7 @@ export class WorkingGroupDatabase { */ async getWorkingGroupsNeedingSummaryRefresh(limit = 20): Promise<string[]> { const result = await query<{ id: string }>( - `SELECT DISTINCT wg.id + `SELECT wg.id FROM working_groups wg LEFT JOIN committee_summaries cs ON cs.working_group_id = wg.id AND cs.summary_type = 'activity' From b27d57631ad3242fabcf76841c997ee13a88502a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <bokelley@scope3.com> Date: Mon, 12 Jan 2026 11:59:06 -0500 Subject: [PATCH 48/77] feat: add meeting management with Zoom and Google Calendar integration (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add meeting management system with Zoom and Google Calendar integration Adds a complete meeting management system for working groups including: - Database schema for meetings, meeting series, attendees, and topics - Zoom API integration for creating/managing meetings with S2S OAuth - Google Calendar integration for sending invites via OAuth or service account - Admin UI for managing meetings and series - Public UI for viewing upcoming meetings and RSVP - Addie MCP tools for scheduling meetings via Slack commands - Webhook support for Zoom recording transcripts Security improvements from code review: - UUID validation on all route parameters - HMAC-SHA256 signature verification for Zoom webhooks - Generic error messages (no sensitive data exposure) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: rename migration to 161 to avoid conflict with main 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --- .changeset/cuddly-windows-accept.md | 2 + package-lock.json | 337 +++++++- package.json | 1 + scripts/google-oauth-setup.ts | 1 + server/public/admin-meetings.html | 793 +++++++++++++++++++ server/public/admin-sidebar.js | 1 + server/public/admin-working-groups.html | 1 + server/public/meetings.html | 603 ++++++++++++++ server/public/nav.js | 6 +- server/public/working-groups/detail.html | 180 +++++ server/src/addie/handler.ts | 12 + server/src/addie/mcp/meeting-tools.ts | 683 ++++++++++++++++ server/src/db/meetings-db.ts | 727 +++++++++++++++++ server/src/db/migrations/161_meetings.sql | 322 ++++++++ server/src/http.ts | 18 + server/src/integrations/google-calendar.ts | 457 +++++++++++ server/src/integrations/zoom.ts | 388 +++++++++ server/src/routes/meetings.ts | 878 +++++++++++++++++++++ server/src/services/meeting-service.ts | 498 ++++++++++++ server/src/types.ts | 249 ++++++ 20 files changed, 6120 insertions(+), 37 deletions(-) create mode 100644 .changeset/cuddly-windows-accept.md create mode 100644 server/public/admin-meetings.html create mode 100644 server/public/meetings.html create mode 100644 server/src/addie/mcp/meeting-tools.ts create mode 100644 server/src/db/meetings-db.ts create mode 100644 server/src/db/migrations/161_meetings.sql create mode 100644 server/src/integrations/google-calendar.ts create mode 100644 server/src/integrations/zoom.ts create mode 100644 server/src/routes/meetings.ts create mode 100644 server/src/services/meeting-service.ts 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/package-lock.json b/package-lock.json index 094ed2e26c..cfce179aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "express": "^4.22.1", "express-rate-limit": "^8.2.1", "free-email-domains": "^1.2.21", + "googleapis": "^169.0.0", "jose": "^6.1.3", "jsonwebtoken": "^9.0.3", "linkedom": "^0.18.12", @@ -2387,7 +2388,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -2405,7 +2405,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2418,7 +2417,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -4804,7 +4802,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8690,7 +8687,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -8801,7 +8797,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8811,7 +8806,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -9277,7 +9271,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -9467,6 +9460,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -9612,7 +9614,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10478,7 +10479,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -10491,7 +10491,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -10873,7 +10872,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -11357,7 +11355,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -11399,7 +11396,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -12224,7 +12220,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, "license": "MIT" }, "node_modules/extendable-error": { @@ -12408,6 +12403,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -12514,7 +12532,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -12562,6 +12579,18 @@ "node": ">=0.4.x" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -12737,6 +12766,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/gcd": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/gcd/-/gcd-0.0.1.tgz", @@ -12744,6 +12815,20 @@ "dev": true, "license": "MIT" }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -12983,6 +13068,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "169.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-169.0.0.tgz", + "integrity": "sha512-IOGMG8tljCZSLvYgdojRu6mB10KEsK0J7X62sXXlQz9koe5BUAW+rqkY3qhQM9wXM6hVL3/Hase7XbxoMyeYiQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -13038,6 +13179,19 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -13655,7 +13809,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -15881,6 +16034,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -17732,7 +17894,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -18044,6 +18205,26 @@ "node": ">=10" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -18541,7 +18722,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { @@ -20627,6 +20807,93 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -21229,7 +21496,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -21707,7 +21973,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -21726,7 +21991,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -21741,14 +22005,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -21758,7 +22020,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -21771,7 +22032,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -21861,7 +22121,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -21875,7 +22134,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -23276,6 +23534,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/urlpattern-polyfill": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", @@ -24172,6 +24436,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webcrypto-core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", @@ -24412,7 +24685,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -24431,7 +24703,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -24449,14 +24720,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -24466,7 +24735,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -24481,7 +24749,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -24494,7 +24761,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -24507,7 +24773,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" diff --git a/package.json b/package.json index c04c719dce..b54bdb8296 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "express": "^4.22.1", "express-rate-limit": "^8.2.1", "free-email-domains": "^1.2.21", + "googleapis": "^169.0.0", "jose": "^6.1.3", "jsonwebtoken": "^9.0.3", "linkedom": "^0.18.12", diff --git a/scripts/google-oauth-setup.ts b/scripts/google-oauth-setup.ts index 18a1f6a671..e16ea36806 100644 --- a/scripts/google-oauth-setup.ts +++ b/scripts/google-oauth-setup.ts @@ -20,6 +20,7 @@ const REDIRECT_PORT = 9876; const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`; const SCOPES = [ + 'https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/documents.readonly', 'https://www.googleapis.com/auth/drive.readonly', ]; 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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="icon" href="/AAo.svg" type="image/svg+xml"> + <title>Admin - Meetings - AgenticAdvertising.org + + + + + + +
+ +
+ Loading... +
+ + + + + + + + + + + + diff --git a/server/public/admin-sidebar.js b/server/public/admin-sidebar.js index feaec17bfe..3dc75dce5c 100644 --- a/server/public/admin-sidebar.js +++ b/server/public/admin-sidebar.js @@ -28,6 +28,7 @@ label: 'Community', items: [ { href: '/admin/events', label: 'Events', icon: '📅' }, + { href: '/admin/meetings', label: 'Meetings', icon: '🗓️' }, { href: '/admin/working-groups', label: 'Working Groups', icon: '🏛️' }, { href: '/admin/perspectives', label: 'Perspectives', icon: '💡' }, ] diff --git a/server/public/admin-working-groups.html b/server/public/admin-working-groups.html index ae4741eae4..153ec09bf4 100644 --- a/server/public/admin-working-groups.html +++ b/server/public/admin-working-groups.html @@ -776,6 +776,7 @@

${escapeHtml(group.name)}

+
diff --git a/server/public/meetings.html b/server/public/meetings.html new file mode 100644 index 0000000000..18b17acb58 --- /dev/null +++ b/server/public/meetings.html @@ -0,0 +1,603 @@ + + + + + + Meetings | AgenticAdvertising.org + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+

Upcoming Meetings

+
+
+ + + +

Loading meetings...

+
+
+
+
+ + + + diff --git a/server/public/nav.js b/server/public/nav.js index 0a76144616..82f4a9107d 100644 --- a/server/public/nav.js +++ b/server/public/nav.js @@ -214,11 +214,12 @@ // Build committees dropdown const committeesBaseUrl = isLocal ? '/committees' : 'https://agenticadvertising.org/committees'; + const meetingsUrl = isLocal ? '/meetings' : 'https://agenticadvertising.org/meetings'; const workingGroupsUrl = `${committeesBaseUrl}?type=working_group`; const councilsUrl = `${committeesBaseUrl}?type=council`; const chaptersUrl = `${committeesBaseUrl}?type=chapter`; const gatheringsUrl = `${committeesBaseUrl}?type=industry_gathering`; - const isCommitteesActive = currentPath.startsWith('/committees') || currentPath.startsWith('/working-groups') || currentPath.startsWith('/industry-gatherings'); + const isCommitteesActive = currentPath.startsWith('/committees') || currentPath.startsWith('/working-groups') || currentPath.startsWith('/industry-gatherings') || currentPath.startsWith('/meetings'); const committeesDropdown = membershipEnabled ? ` @@ -2439,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(); @@ -2447,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') { @@ -4812,6 +4815,7 @@

c.name.toLowerCase().includes('escalation') || c.name.toLowerCase().includes('addie-admin') + ); + return escalationChannel?.slack_channel_id || null; +} + +/** + * Format and send escalation notification to Slack + */ +async function sendEscalationNotification( + escalationId: number, + channelId: string, + summary: string, + category: EscalationCategory, + priority: EscalationPriority, + context: { + userDisplayName?: string; + orgName?: string; + slackUserId?: string; + threadId?: string; + originalRequest?: string; + addieContext?: string; + } +): Promise<{ ok: boolean; ts?: string }> { + const priorityEmoji: Record = { + low: '', + normal: '', + high: ':warning:', + urgent: ':rotating_light:', + }; + + const categoryLabel: Record = { + capability_gap: 'Capability Gap', + needs_human_action: 'Needs Human Action', + complex_request: 'Complex Request', + sensitive_topic: 'Sensitive Topic', + other: 'Other', + }; + + const userInfo = context.userDisplayName + ? `${context.userDisplayName}${context.orgName ? ` (${context.orgName})` : ''}` + : context.slackUserId + ? `<@${context.slackUserId}>` + : 'Unknown user'; + + const lines = [ + `${priorityEmoji[priority]} *New Escalation #${escalationId}*`, + '', + `*From:* ${userInfo}`, + `*Category:* ${categoryLabel[category]}`, + `*Priority:* ${priority}`, + '', + `*Summary:* ${summary}`, + ]; + + if (context.originalRequest) { + lines.push('', `*Original Request:* ${context.originalRequest}`); + } + + if (context.addieContext) { + lines.push('', `*Why Escalated:* ${context.addieContext}`); + } + + if (context.threadId) { + lines.push('', ``); + } + + return sendChannelMessage(channelId, { text: lines.join('\n') }); +} + +/** + * Create handlers for escalation and learning tools + */ +export function createEscalationToolHandlers( + memberContext: MemberContext | null, + slackUserId?: string, + threadId?: string +): Map) => Promise> { + const handlers = new Map) => Promise>(); + + // ============================================ + // ESCALATE TO ADMIN + // ============================================ + handlers.set('escalate_to_admin', async (input) => { + // Validate required inputs + if (typeof input.summary !== 'string' || !input.summary.trim()) { + return 'Error: summary is required and must be a non-empty string'; + } + + const validCategories: EscalationCategory[] = [ + 'capability_gap', 'needs_human_action', 'complex_request', 'sensitive_topic', 'other' + ]; + if (!validCategories.includes(input.category as EscalationCategory)) { + return `Error: category must be one of: ${validCategories.join(', ')}`; + } + + const validPriorities: EscalationPriority[] = ['low', 'normal', 'high', 'urgent']; + if (input.priority && !validPriorities.includes(input.priority as EscalationPriority)) { + return `Error: priority must be one of: ${validPriorities.join(', ')}`; + } + + const summary = input.summary as string; + const category = input.category as EscalationCategory; + const priority = (input.priority as EscalationPriority) || 'normal'; + const originalRequest = input.original_request as string | undefined; + const addieContext = input.addie_context as string | undefined; + + // Get display name from slack_user or workos_user + const userDisplayName = memberContext?.slack_user?.display_name + ?? (memberContext?.workos_user?.first_name + ? `${memberContext.workos_user.first_name} ${memberContext.workos_user.last_name || ''}`.trim() + : undefined); + const orgName = memberContext?.organization?.name; + + try { + // 1. Create escalation record + const escalation = await createEscalation({ + thread_id: threadId, + slack_user_id: slackUserId, + workos_user_id: memberContext?.workos_user?.workos_user_id, + user_display_name: userDisplayName, + category, + priority, + summary, + original_request: originalRequest, + addie_context: addieContext, + }); + + logger.info( + { escalationId: escalation.id, category, priority, threadId }, + 'Created escalation' + ); + + // 2. Flag the thread + if (threadId) { + const threadService = getThreadService(); + await threadService.flagThread(threadId, `Escalation: ${category}`); + } + + // 3. Send notification to escalation channel + const escalationChannelId = await getEscalationChannelId(); + if (escalationChannelId) { + const result = await sendEscalationNotification( + escalation.id, + escalationChannelId, + summary, + category, + priority, + { + userDisplayName, + orgName, + slackUserId, + threadId, + originalRequest, + addieContext, + } + ); + + if (result.ok && result.ts) { + try { + await markNotificationSent(escalation.id, escalationChannelId, result.ts); + logger.info({ escalationId: escalation.id, channelId: escalationChannelId }, 'Sent escalation notification'); + } catch (notifyError) { + logger.error( + { escalationId: escalation.id, error: notifyError }, + 'Failed to record notification status - notification was sent but DB update failed' + ); + } + } + } else { + logger.warn({ escalationId: escalation.id }, 'No escalation channel configured - notification not sent'); + } + + return `Escalation created (ID: ${escalation.id}). I've notified the AgenticAdvertising.org team and they'll follow up with you soon.`; + } catch (error) { + logger.error({ error, category, threadId }, 'Failed to create escalation'); + return 'I tried to escalate this but encountered an error. Please reach out directly to the AgenticAdvertising.org team for help.'; + } + }); + + // ============================================ + // CAPTURE LEARNING + // ============================================ + handlers.set('capture_learning', async (input) => { + // Validate required inputs + if (typeof input.topic !== 'string' || !input.topic.trim()) { + return 'Error: topic is required and must be a non-empty string'; + } + if (typeof input.summary !== 'string' || !input.summary.trim()) { + return 'Error: summary is required and must be a non-empty string'; + } + + const topic = input.topic as string; + const summary = input.summary as string; + const whyValuable = input.why_valuable as string | undefined; + + // Get display name from slack_user or workos_user + const authorDisplayName = memberContext?.slack_user?.display_name + ?? (memberContext?.workos_user?.first_name + ? `${memberContext.workos_user.first_name} ${memberContext.workos_user.last_name || ''}`.trim() + : undefined); + const authorOrgName = memberContext?.organization?.name; + + try { + // Get recent conversation content for context + let content = summary; + if (threadId) { + const threadService = getThreadService(); + const thread = await threadService.getThreadWithMessages(threadId); + if (thread?.messages && thread.messages.length > 0) { + // Get last few messages for context + const recentMessages = thread.messages.slice(-5); + content = recentMessages + .map((m) => `${m.role}: ${m.content}`) + .join('\n\n'); + } + } + + // Create insight source record + const addieDb = new AddieDatabase(); + const source = await addieDb.createInsightSource({ + source_type: 'conversation', + source_ref: threadId, + content, + topic, + author_name: authorDisplayName, + author_context: authorOrgName, + tagged_by: 'addie', + notes: whyValuable, + }); + + logger.info( + { insightId: source.id, topic, threadId, authorName: authorDisplayName }, + 'Captured learning from conversation' + ); + + return `Learning captured (ID: ${source.id}). Thank you for sharing this insight - it helps us improve!`; + } catch (error) { + logger.error({ error, topic, threadId }, 'Failed to capture learning'); + return 'I noted this insight but encountered an error saving it. The team may follow up.'; + } + }); + + return handlers; +} diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts index 244c6e582b..daa6c3258a 100644 --- a/server/src/addie/prompts.ts +++ b/server/src/addie/prompts.ts @@ -331,6 +331,19 @@ When asked "what's the latest news" - interpret as AD TECH news. Search for AdCP - **Source attribution**: Always cite sources; link to documentation; distinguish fact from interpretation - **GitHub issues**: When users report bugs, broken links, or feature requests, use draft_github_issue to help them create an issue. Important: Always include the full tool output (GitHub link, preview) in your response since users cannot see tool outputs directly. +## Honesty About Actions + +You can only perform actions through your available tools. Before promising to do something: +1. Check if you have a tool that actually performs that action +2. If you don't have the tool, do NOT say "I'll do that" or "I'm doing X now" +3. Instead, be honest: explain what you can help with and offer to escalate using \`escalate_to_admin\` + +Example: +- BAD: "I'll post that announcement to the channel now" (when you have no posting tool) +- GOOD: "I can't post to channels directly, but I can help you draft the message. Want me to escalate this to an admin who can post it for you?" + +When you genuinely can't help with something, use \`escalate_to_admin\` to create a tracked request for the team. This ensures nothing falls through the cracks. + ## Fact-Checking (CRITICAL) **NEVER invent or assume facts.** If you're unsure about something, use your tools to verify: diff --git a/server/src/addie/thread-service.ts b/server/src/addie/thread-service.ts index 931bf9bc44..3bb82c411b 100644 --- a/server/src/addie/thread-service.ts +++ b/server/src/addie/thread-service.ts @@ -209,6 +209,7 @@ export interface ThreadListFilters { unreviewed_only?: boolean; has_feedback?: boolean; has_user_feedback?: boolean; + min_messages?: number; since?: Date; limit?: number; offset?: number; @@ -547,6 +548,11 @@ export class ThreadService { conditions.push(`user_feedback_count > 0`); } + if (filters.min_messages !== undefined && filters.min_messages > 0) { + conditions.push(`message_count >= $${paramIndex++}`); + params.push(filters.min_messages); + } + if (filters.since) { conditions.push(`started_at >= $${paramIndex++}`); params.push(filters.since); diff --git a/server/src/db/escalation-db.ts b/server/src/db/escalation-db.ts new file mode 100644 index 0000000000..cfb135f807 --- /dev/null +++ b/server/src/db/escalation-db.ts @@ -0,0 +1,270 @@ +/** + * Database layer for Addie escalations + * Tracks requests that Addie escalates to human admins + */ + +import { query } from './client.js'; + +// ============== Types ============== + +export type EscalationCategory = + | 'capability_gap' + | 'needs_human_action' + | 'complex_request' + | 'sensitive_topic' + | 'other'; + +export type EscalationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +export type EscalationStatus = + | 'open' + | 'acknowledged' + | 'in_progress' + | 'resolved' + | 'wont_do' + | 'expired'; + +export interface Escalation { + id: number; + thread_id: string | null; + message_id: string | null; + slack_user_id: string | null; + workos_user_id: string | null; + user_display_name: string | null; + category: EscalationCategory; + priority: EscalationPriority; + summary: string; + original_request: string | null; + addie_context: string | null; + notification_channel_id: string | null; + notification_sent_at: Date | null; + notification_message_ts: string | null; + status: EscalationStatus; + resolved_by: string | null; + resolved_at: Date | null; + resolution_notes: string | null; + created_at: Date; + updated_at: Date; +} + +export interface EscalationInput { + thread_id?: string; + message_id?: string; + slack_user_id?: string; + workos_user_id?: string; + user_display_name?: string; + category: EscalationCategory; + priority?: EscalationPriority; + summary: string; + original_request?: string; + addie_context?: string; +} + +export interface EscalationFilters { + status?: EscalationStatus; + category?: EscalationCategory; + limit?: number; + offset?: number; +} + +// ============== Escalation Operations ============== + +/** + * Create a new escalation + */ +export async function createEscalation(input: EscalationInput): Promise { + const result = await query( + `INSERT INTO addie_escalations ( + thread_id, message_id, slack_user_id, workos_user_id, user_display_name, + category, priority, summary, original_request, addie_context + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + input.thread_id || null, + input.message_id || null, + input.slack_user_id || null, + input.workos_user_id || null, + input.user_display_name || null, + input.category, + input.priority || 'normal', + input.summary, + input.original_request || null, + input.addie_context || null, + ] + ); + return result.rows[0]; +} + +/** + * Get a single escalation by ID + */ +export async function getEscalation(id: number): Promise { + const result = await query( + `SELECT * FROM addie_escalations WHERE id = $1`, + [id] + ); + return result.rows[0] || null; +} + +/** + * List escalations with optional filters + */ +export async function listEscalations(filters: EscalationFilters = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters.category) { + conditions.push(`category = $${paramIndex++}`); + params.push(filters.category); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + // Assign parameter indexes explicitly for clarity + const limitParamIndex = paramIndex++; + const offsetParamIndex = paramIndex; + params.push(limit, offset); + + const result = await query( + `SELECT * FROM addie_escalations + ${whereClause} + ORDER BY + CASE priority + WHEN 'urgent' THEN 1 + WHEN 'high' THEN 2 + WHEN 'normal' THEN 3 + WHEN 'low' THEN 4 + END, + created_at DESC + LIMIT $${limitParamIndex} OFFSET $${offsetParamIndex}`, + params + ); + return result.rows; +} + +/** + * Count escalations with filters (for pagination) + */ +export async function countEscalations( + filters: Omit = {} +): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters.category) { + conditions.push(`category = $${paramIndex++}`); + params.push(filters.category); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM addie_escalations ${whereClause}`, + params + ); + return parseInt(result.rows[0]?.count || '0', 10); +} + +/** + * Get open escalations count by category + */ +export async function getEscalationStats(): Promise<{ + open: number; + resolved_today: number; + by_category: Record; +}> { + const [openCount, resolvedToday, byCategory] = await Promise.all([ + query<{ count: string }>( + `SELECT COUNT(*) as count FROM addie_escalations WHERE status = 'open'` + ), + query<{ count: string }>( + `SELECT COUNT(*) as count FROM addie_escalations + WHERE status = 'resolved' AND resolved_at > NOW() - INTERVAL '24 hours'` + ), + query<{ category: string; count: string }>( + `SELECT category, COUNT(*) as count FROM addie_escalations + WHERE status = 'open' + GROUP BY category` + ), + ]); + + const byCategoryMap: Record = {}; + for (const row of byCategory.rows) { + byCategoryMap[row.category] = parseInt(row.count, 10); + } + + return { + open: parseInt(openCount.rows[0]?.count || '0', 10), + resolved_today: parseInt(resolvedToday.rows[0]?.count || '0', 10), + by_category: byCategoryMap, + }; +} + +/** + * Update escalation status + */ +export async function updateEscalationStatus( + id: number, + status: EscalationStatus, + resolvedBy?: string, + notes?: string +): Promise { + const isResolved = status === 'resolved' || status === 'wont_do' || status === 'expired'; + + const result = await query( + `UPDATE addie_escalations + SET status = $2, + resolved_by = CASE WHEN $3::text IS NOT NULL THEN $3 ELSE resolved_by END, + resolved_at = CASE WHEN $4 THEN NOW() ELSE resolved_at END, + resolution_notes = CASE WHEN $5::text IS NOT NULL THEN $5 ELSE resolution_notes END, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, status, resolvedBy || null, isResolved, notes || null] + ); + return result.rows[0] || null; +} + +/** + * Mark notification as sent + */ +export async function markNotificationSent( + id: number, + channelId: string, + messageTs: string +): Promise { + await query( + `UPDATE addie_escalations + SET notification_channel_id = $2, + notification_sent_at = NOW(), + notification_message_ts = $3, + updated_at = NOW() + WHERE id = $1`, + [id, channelId, messageTs] + ); +} + +/** + * Get escalations for a specific thread + */ +export async function getEscalationsForThread(threadId: string): Promise { + const result = await query( + `SELECT * FROM addie_escalations WHERE thread_id = $1 ORDER BY created_at DESC`, + [threadId] + ); + return result.rows; +} diff --git a/server/src/db/migrations/167_addie_escalations.sql b/server/src/db/migrations/167_addie_escalations.sql new file mode 100644 index 0000000000..a3fb2d2b4d --- /dev/null +++ b/server/src/db/migrations/167_addie_escalations.sql @@ -0,0 +1,62 @@ +-- Addie Escalations System +-- Tracks escalations when Addie encounters capability gaps or needs human help + +CREATE TABLE addie_escalations ( + id SERIAL PRIMARY KEY, + + -- Source tracking + thread_id UUID REFERENCES addie_threads(thread_id), + message_id UUID REFERENCES addie_thread_messages(message_id), + + -- User who requested help + slack_user_id TEXT, + workos_user_id TEXT, + user_display_name TEXT, + + -- Escalation details + category TEXT NOT NULL CHECK (category IN ( + 'capability_gap', -- Addie can't do this action + 'needs_human_action', -- Requires human to take action + 'complex_request', -- Too complex for Addie + 'sensitive_topic', -- Needs human judgment + 'other' + )), + priority TEXT DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')), + + -- Context + summary TEXT NOT NULL, + original_request TEXT, + addie_context TEXT, + + -- Notification tracking + notification_channel_id TEXT, + notification_sent_at TIMESTAMP WITH TIME ZONE, + notification_message_ts TEXT, + + -- Resolution workflow + status TEXT DEFAULT 'open' CHECK (status IN ( + 'open', + 'acknowledged', + 'in_progress', + 'resolved', + 'wont_do', + 'expired' + )), + resolved_by TEXT, + resolved_at TIMESTAMP WITH TIME ZONE, + resolution_notes TEXT, + + -- Audit + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_escalations_status ON addie_escalations(status, created_at DESC); +CREATE INDEX idx_escalations_thread ON addie_escalations(thread_id) WHERE thread_id IS NOT NULL; +CREATE INDEX idx_escalations_user ON addie_escalations(slack_user_id) WHERE slack_user_id IS NOT NULL; + +-- Update trigger for updated_at +CREATE TRIGGER update_escalations_updated_at + BEFORE UPDATE ON addie_escalations + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/server/src/routes/addie-admin.ts b/server/src/routes/addie-admin.ts index 8264f0646b..f355bbf4b8 100644 --- a/server/src/routes/addie-admin.ts +++ b/server/src/routes/addie-admin.ts @@ -27,6 +27,15 @@ import { resolveSlackUserDisplayName, resolveSlackUserDisplayNames, } from "../slack/client.js"; +import { + listEscalations, + countEscalations, + getEscalation, + updateEscalationStatus, + getEscalationStats, + type EscalationStatus, + type EscalationCategory, +} from "../db/escalation-db.js"; const logger = createLogger("addie-admin-routes"); const addieDb = new AddieDatabase(); @@ -364,13 +373,14 @@ export function createAddieAdminRouter(): { pageRouter: Router; apiRouter: Route apiRouter.get("/threads", requireAuth, requireAdmin, async (req, res) => { try { const threadService = getThreadService(); - const { channel, flagged_only, unreviewed_only, has_user_feedback, user_id, since, limit, offset } = req.query; + const { channel, flagged_only, unreviewed_only, has_user_feedback, min_messages, user_id, since, limit, offset } = req.query; const threads = await threadService.listThreads({ channel: channel as ThreadChannel | undefined, flagged_only: flagged_only === "true", unreviewed_only: unreviewed_only === "true", has_user_feedback: has_user_feedback === "true", + min_messages: min_messages ? parseInt(min_messages as string, 10) : undefined, user_id: user_id as string | undefined, since: since ? new Date(since as string) : undefined, limit: limit ? parseInt(limit as string, 10) : 50, @@ -2811,5 +2821,132 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be } }); + // ========================================================================= + // ESCALATION MANAGEMENT API (mounted at /api/admin/addie/escalations) + // ========================================================================= + + // GET /api/admin/addie/escalations - List escalations with optional filters + apiRouter.get("/escalations", requireAuth, requireAdmin, async (req, res) => { + try { + const { status, category, limit, offset } = req.query; + + const filters = { + status: status as EscalationStatus | undefined, + category: category as EscalationCategory | undefined, + }; + const parsedLimit = limit ? parseInt(limit as string, 10) : 50; + const parsedOffset = offset ? parseInt(offset as string, 10) : 0; + + const [escalations, totalCount, stats] = await Promise.all([ + listEscalations({ + ...filters, + limit: parsedLimit, + offset: parsedOffset, + }), + countEscalations(filters), + getEscalationStats(), + ]); + + res.json({ + escalations, + stats, + total: totalCount, + limit: parsedLimit, + offset: parsedOffset, + }); + } catch (error) { + logger.error({ err: error }, "Error fetching escalations"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch escalations", + }); + } + }); + + // GET /api/admin/addie/escalations/:id - Get single escalation with thread context + apiRouter.get("/escalations/:id", requireAuth, requireAdmin, async (req, res) => { + try { + const id = parseNumericId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Bad request", message: "Invalid ID" }); + } + + const escalation = await getEscalation(id); + if (!escalation) { + return res.status(404).json({ error: "Not found", message: "Escalation not found" }); + } + + // If escalation has a thread, get thread context + let threadContext = null; + if (escalation.thread_id) { + const threadService = getThreadService(); + const thread = await threadService.getThreadWithMessages(escalation.thread_id); + if (thread) { + threadContext = { + thread_id: thread.thread_id, + channel: thread.channel, + created_at: thread.created_at, + messages: thread.messages?.slice(-10), // Last 10 messages + }; + } + } + + res.json({ + escalation, + thread: threadContext, + }); + } catch (error) { + logger.error({ err: error }, "Error fetching escalation"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch escalation", + }); + } + }); + + // PATCH /api/admin/addie/escalations/:id - Update escalation status + apiRouter.patch("/escalations/:id", requireAuth, requireAdmin, async (req, res) => { + try { + const id = parseNumericId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Bad request", message: "Invalid ID" }); + } + + const { status, notes } = req.body; + if (!status) { + return res.status(400).json({ error: "Bad request", message: "Status is required" }); + } + + const validStatuses: EscalationStatus[] = [ + 'open', 'acknowledged', 'in_progress', 'resolved', 'wont_do', 'expired' + ]; + if (!validStatuses.includes(status)) { + return res.status(400).json({ error: "Bad request", message: "Invalid status" }); + } + + // Get current user from session for resolved_by + const user = (req as unknown as { user?: { email?: string } }).user; + const resolvedBy = user?.email || 'admin'; + + const updated = await updateEscalationStatus(id, status, resolvedBy, notes); + if (!updated) { + return res.status(404).json({ error: "Not found", message: "Escalation not found" }); + } + + logger.info({ escalationId: id, status, resolvedBy }, "Escalation status updated"); + + res.json({ + success: true, + escalation: updated, + }); + } catch (error) { + logger.error({ err: error }, "Error updating escalation"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to update escalation", + }); + } + }); + return { pageRouter, apiRouter }; } From b61b4469ff20f4ec70d7fcb20138ffc0ce31915c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 13 Jan 2026 11:19:34 -0500 Subject: [PATCH 70/77] feat: add call_adcp_agent tool and Claude Skills for full AdCP access (#749) * feat: add call_adcp_agent tool and Claude Skills for full AdCP access Enable clients to execute the full AdCP spec (not just testing API) via: - `call_adcp_agent` tool: Low-level proxy to any AdCP-compliant 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. Includes: - SSRF protection via URL validation (HTTPS only, no internal networks) - Auto-generation of skill schemas during build - Quick reference docs for all three protocols Co-Authored-By: Claude Opus 4.5 * Address PR review comments - Update example dates from 2024 to 2026 in media-buy-quick-reference.mdx - Refactor build-schemas.cjs to reduce code duplication using helper function Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/fiery-maps-tan.md | 12 + .gitignore | 3 + docs.json | 3 + docs/reference/creative-quick-reference.mdx | 285 +++++++++++++++ docs/reference/media-buy-quick-reference.mdx | 321 +++++++++++++++++ docs/reference/signals-quick-reference.mdx | 200 +++++++++++ scripts/build-schemas.cjs | 80 +++++ server/src/addie/mcp/member-tools.ts | 126 +++++++ skills/adcp-creative/SKILL.md | 289 ++++++++++++++++ skills/adcp-media-buy/SKILL.md | 345 +++++++++++++++++++ skills/adcp-signals/SKILL.md | 205 +++++++++++ 11 files changed, 1869 insertions(+) create mode 100644 .changeset/fiery-maps-tan.md create mode 100644 docs/reference/creative-quick-reference.mdx create mode 100644 docs/reference/media-buy-quick-reference.mdx create mode 100644 docs/reference/signals-quick-reference.mdx create mode 100644 skills/adcp-creative/SKILL.md create mode 100644 skills/adcp-media-buy/SKILL.md create mode 100644 skills/adcp-signals/SKILL.md 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/.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/docs.json b/docs.json index cf2262d30a..a88bdd852c 100644 --- a/docs.json +++ b/docs.json @@ -204,6 +204,9 @@ { "group": "Reference", "pages": [ + "docs/reference/media-buy-quick-reference", + "docs/reference/signals-quick-reference", + "docs/reference/creative-quick-reference", "docs/reference/roadmap", "docs/reference/release-notes", "docs/reference/changelog", diff --git a/docs/reference/creative-quick-reference.mdx b/docs/reference/creative-quick-reference.mdx new file mode 100644 index 0000000000..6fe5ccb9d0 --- /dev/null +++ b/docs/reference/creative-quick-reference.mdx @@ -0,0 +1,285 @@ +--- +title: Creative Quick Reference +description: Compact reference for executing AdCP Creative Protocol tasks. Use this when interacting with creative agents via call_adcp_agent. +keywords: [adcp tasks, creative api, creative agent, build_creative, preview_creative, quick reference] +--- + +Use the `call_adcp_agent` tool to execute these tasks against any AdCP creative agent. + +## Task Overview + +| Task | Purpose | Response Time | +|------|---------|---------------| +| `list_creative_formats` | View format specifications | ~1s | +| `build_creative` | Generate or transform creatives | ~30s-5m | +| `preview_creative` | Get visual previews | ~5s | + +## Typical Workflow + +1. **Discover formats**: `list_creative_formats` to see available format specs +2. **Build creative**: `build_creative` to generate or transform a manifest +3. **Preview**: `preview_creative` to see how it renders +4. **Sync**: Use `sync_creatives` (media-buy task) to traffic the creative + +--- + +## Task Reference + +### list_creative_formats + +Discover creative formats and their specifications. + +```json +{ + "type": "video", + "asset_types": ["image", "text"] +} +``` + +**Key fields:** +- `format_ids` (array, optional): Request specific format IDs +- `type` (string, optional): Filter by type: `video`, `display`, `audio`, `dooh` +- `asset_types` (array, optional): Filter by accepted asset types +- `max_width`, `max_height` (integer, optional): Dimension constraints +- `is_responsive` (boolean, optional): Filter for responsive formats +- `name_search` (string, optional): Search formats by name + +**Response contains:** +- `formats`: Array of format definitions with `format_id`, `name`, `type`, `assets_required`, `renders` +- `creative_agents`: Optional array of other creative agents providing additional formats + +--- + +### build_creative + +Generate a creative from scratch or transform an existing creative to a different format. + +**Pure Generation (from brief):** +```json +{ + "message": "Create a banner promoting our winter sale with a warm, inviting feel", + "target_format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250_generative" + }, + "creative_manifest": { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250_generative" + }, + "assets": { + "promoted_offerings": { + "brand_manifest": { + "url": "https://mybrand.com", + "name": "My Brand", + "colors": { "primary": "#FF5733" } + }, + "inline_offerings": [ + { + "name": "Winter Sale Collection", + "description": "50% off all winter items" + } + ] + } + } + } +} +``` + +**Transformation (resize/reformat):** +```json +{ + "message": "Adapt this leaderboard to a 300x250 banner", + "creative_manifest": { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_728x90" + }, + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.mybrand.com/leaderboard.png", + "width": 728, + "height": 90 + }, + "headline": { + "asset_type": "text", + "content": "Spring Sale - 30% Off" + } + } + }, + "target_format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + } +} +``` + +**Key fields:** +- `message` (string, optional): Natural language instructions for generation/transformation +- `creative_manifest` (object, optional): Source manifest - minimal for generation, complete for transformation +- `target_format_id` (object, required): Format to generate - `{ agent_url, id }` + +**Response contains:** +- `creative_manifest`: Complete manifest ready for `preview_creative` or `sync_creatives` + +--- + +### preview_creative + +Generate visual previews of creative manifests. + +**Single preview:** +```json +{ + "request_type": "single", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + }, + "creative_manifest": { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + }, + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.example.com/banner.png", + "width": 300, + "height": 250 + } + } + } +} +``` + +**With device variants:** +```json +{ + "request_type": "single", + "format_id": { "agent_url": "...", "id": "native_responsive" }, + "creative_manifest": { }, + "inputs": [ + { "name": "Desktop", "macros": { "DEVICE_TYPE": "desktop" } }, + { "name": "Mobile", "macros": { "DEVICE_TYPE": "mobile" } } + ] +} +``` + +**Batch preview (5-10x faster):** +```json +{ + "request_type": "batch", + "requests": [ + { "format_id": {}, "creative_manifest": { } }, + { "format_id": {}, "creative_manifest": { } } + ] +} +``` + +**Key fields:** +- `request_type` (string, required): `"single"` or `"batch"` +- `format_id` (object, required for single): Format identifier +- `creative_manifest` (object, required): Complete creative manifest +- `inputs` (array, optional): Generate variants with different macros/contexts +- `output_format` (string, optional): `"url"` (default) or `"html"` + +**Response contains:** +- `previews`: Array of preview objects with `preview_url` or `preview_html` +- `expires_at`: When preview URLs expire + +--- + +## Key Concepts + +### Format IDs + +All format references use structured objects: +```json +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + } +} +``` + +The `agent_url` specifies the creative agent authoritative for this format. + +### Creative Manifests + +Manifests pair format specifications with actual assets: +```json +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + }, + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.example.com/banner.png", + "width": 300, + "height": 250 + }, + "headline": { + "asset_type": "text", + "content": "Shop Now" + }, + "clickthrough_url": { + "asset_type": "url", + "url": "https://brand.com/sale" + } + } +} +``` + +### Asset Types + +Common asset types: +- `image`: Static images (JPEG, PNG, WebP) +- `video`: Video files (MP4, WebM) or VAST tags +- `audio`: Audio files (MP3, M4A) or DAAST tags +- `text`: Headlines, descriptions, CTAs +- `html`: HTML5 creatives or third-party tags +- `javascript`: JavaScript tags +- `url`: Tracking pixels, clickthrough URLs + +### Brand Manifest + +For generative creatives, provide brand context: +```json +{ + "brand_manifest": { + "url": "https://brand.com", + "name": "Brand Name", + "colors": { "primary": "#FF0000", "secondary": "#0000FF" } + } +} +``` + +### Generative vs Transformation + +- **Pure Generation**: Minimal manifest with `promoted_offerings` in assets. Creative agent generates all output assets from scratch. +- **Transformation**: Complete manifest with existing assets. Creative agent adapts to target format, following `message` guidance. + +--- + +## Error Handling + +Common error patterns: + +- **400 Bad Request**: Invalid manifest or format_id +- **404 Not Found**: Format not supported by this agent +- **422 Validation Error**: Manifest doesn't match format requirements + +Error responses include: +```json +{ + "error": { + "code": "INVALID_FORMAT_ID", + "message": "format_id must be a structured object with 'agent_url' and 'id' fields" + } +} +``` diff --git a/docs/reference/media-buy-quick-reference.mdx b/docs/reference/media-buy-quick-reference.mdx new file mode 100644 index 0000000000..595bd8efca --- /dev/null +++ b/docs/reference/media-buy-quick-reference.mdx @@ -0,0 +1,321 @@ +--- +title: Media Buy Quick Reference +description: Compact reference for executing AdCP Media Buy Protocol tasks. Use this when interacting with sales agents via call_adcp_agent. +keywords: [adcp tasks, media buy api, sales agent, get_products, create_media_buy, sync_creatives, quick reference] +--- + +Use the `call_adcp_agent` tool to execute these tasks against any AdCP sales agent. + +## Task Overview + +| Task | Purpose | Response Time | +|------|---------|---------------| +| `get_products` | Discover inventory using natural language | ~60s | +| `list_authorized_properties` | See publisher properties | ~1s | +| `list_creative_formats` | View creative specifications | ~1s | +| `create_media_buy` | Create campaigns | Minutes-Days | +| `update_media_buy` | Modify campaigns | Minutes-Days | +| `sync_creatives` | Upload creative assets | Minutes-Days | +| `list_creatives` | Query creative library | ~1s | +| `get_media_buy_delivery` | Get performance data | ~60s | + +## Typical Workflow + +1. **Discover products**: `get_products` with a natural language brief +2. **Review formats**: `list_creative_formats` to understand creative requirements +3. **Create campaign**: `create_media_buy` with selected products and budget +4. **Upload creatives**: `sync_creatives` to add creative assets +5. **Monitor delivery**: `get_media_buy_delivery` to track performance + +--- + +## Task Reference + +### get_products + +Discover advertising products using natural language briefs. + +```json +{ + "brief": "Looking for premium video inventory for a tech brand targeting developers", + "brand_manifest": { + "url": "https://example.com" + }, + "filters": { + "channels": ["video", "ctv"], + "budget_range": { "min": 5000, "max": 50000 } + } +} +``` + +**Key fields:** +- `brief` (string): Natural language description of campaign requirements +- `brand_manifest` (object): Brand context - can be `{ "url": "https://..." }` or inline manifest +- `filters` (object, optional): Filter by channels, budget, delivery_type, format_types + +**Response contains:** +- `products`: Array of matching products with `product_id`, `name`, `description`, `pricing_options` +- Each product includes `format_ids` (supported creative formats) and `targeting` (available targeting) + +--- + +### list_authorized_properties + +Get the list of publisher properties this agent can sell. + +```json +{} +``` + +No parameters required. + +**Response contains:** +- `publisher_domains`: Array of domain strings the agent is authorized to sell + +--- + +### list_creative_formats + +View supported creative specifications. + +```json +{ + "format_types": ["video", "display"] +} +``` + +**Key fields:** +- `format_types` (array, optional): Filter to specific format categories + +**Response contains:** +- `formats`: Array of format specifications with dimensions, requirements, and asset schemas + +--- + +### create_media_buy + +Create an advertising campaign from selected products. + +```json +{ + "buyer_ref": "campaign-2026-q1-001", + "brand_manifest": { + "url": "https://acme.com", + "name": "Acme Corporation" + }, + "packages": [ + { + "buyer_ref": "pkg-video-001", + "product_id": "premium_video_30s", + "pricing_option_id": "cpm-standard", + "budget": 10000 + } + ], + "start_time": { + "type": "asap" + }, + "end_time": "2026-03-31T23:59:59Z" +} +``` + +**Key fields:** +- `buyer_ref` (string, required): Your unique identifier for this campaign +- `brand_manifest` (object, required): Brand identity - URL or inline manifest +- `packages` (array, required): Products to purchase, each with: + - `buyer_ref`: Your identifier for this package + - `product_id`: From `get_products` response + - `pricing_option_id`: From product's `pricing_options` + - `budget`: Amount in dollars + - `bid_price`: Required for auction pricing + - `targeting_overlay`: Additional targeting constraints + - `creative_ids` or `creatives`: Creative assignments +- `start_time` (object, required): `{ "type": "asap" }` or `{ "type": "scheduled", "datetime": "..." }` +- `end_time` (string, required): ISO 8601 datetime + +**Response contains:** +- `media_buy_id`: The created campaign identifier +- `status`: Current state (often `pending` for async approval) +- `packages`: Created packages with their IDs + +--- + +### update_media_buy + +Modify an existing campaign. + +```json +{ + "media_buy_id": "mb_abc123", + "updates": { + "budget_change": 5000, + "end_time": "2026-04-30T23:59:59Z", + "status": "paused" + } +} +``` + +**Key fields:** +- `media_buy_id` (string, required): The campaign to update +- `updates` (object): Changes to apply - budget_change, end_time, status, targeting, etc. + +--- + +### sync_creatives + +Upload and manage creative assets. + +```json +{ + "creatives": [ + { + "creative_id": "hero_video_30s", + "name": "Brand Hero Video", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_standard_30s" + }, + "assets": { + "video": { + "url": "https://cdn.example.com/hero.mp4", + "width": 1920, + "height": 1080, + "duration_ms": 30000 + } + } + } + ], + "assignments": { + "hero_video_30s": ["pkg_001", "pkg_002"] + } +} +``` + +**Key fields:** +- `creatives` (array, required): Creative assets to sync + - `creative_id`: Your unique identifier + - `format_id`: Object with `agent_url` and `id` from format specifications + - `assets`: Asset content (video, image, html, etc.) +- `assignments` (object, optional): Map creative_id to package IDs +- `dry_run` (boolean): Preview changes without applying +- `delete_missing` (boolean): Archive creatives not in this sync + +--- + +### list_creatives + +Query the creative library with filtering. + +```json +{ + "filters": { + "status": ["active"], + "format_types": ["video"] + }, + "limit": 20 +} +``` + +--- + +### get_media_buy_delivery + +Retrieve performance metrics for a campaign. + +```json +{ + "media_buy_id": "mb_abc123", + "granularity": "daily", + "date_range": { + "start": "2026-01-01", + "end": "2026-01-31" + } +} +``` + +**Response contains:** +- `delivery`: Aggregated metrics (impressions, spend, clicks, etc.) +- `by_package`: Breakdown by package +- `timeseries`: Data points over time if granularity specified + +--- + +## Key Concepts + +### Brand Manifest + +Brand context can be provided in two ways: + +**URL reference** (recommended): +```json +{ + "brand_manifest": { + "url": "https://brand.com" + } +} +``` + +**Inline manifest**: +```json +{ + "brand_manifest": { + "name": "Brand Name", + "url": "https://brand.com", + "tagline": "Brand tagline", + "colors": { "primary": "#FF0000" } + } +} +``` + +### Format IDs + +Creative format identifiers are structured objects: +```json +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + } +} +``` + +The `agent_url` specifies which creative agent defines the format. Use `https://creative.adcontextprotocol.org` for standard IAB formats. + +### Pricing Options + +Products include `pricing_options` array. Each option has: +- `pricing_option_id`: Use this in `create_media_buy` +- `pricing_model`: "cpm", "cpm-auction", "flat-fee", etc. +- `price`: Base price (for fixed pricing) +- `floor`: Minimum bid (for auction) + +For auction pricing, include `bid_price` in your package. + +### Asynchronous Operations + +Operations like `create_media_buy` and `sync_creatives` may require human approval. The response includes: +- `status: "pending"` - Operation awaiting approval +- `task_id` - For tracking async progress + +Poll or use webhooks to check completion status. + +--- + +## Error Handling + +Common error patterns: + +- **400 Bad Request**: Invalid parameters - check required fields +- **401 Unauthorized**: Invalid or missing authentication token +- **404 Not Found**: Invalid product_id, media_buy_id, or creative_id +- **422 Validation Error**: Schema validation failure - check field types + +Error responses include: +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "budget must be greater than 0", + "field": "packages[0].budget" + } +} +``` diff --git a/docs/reference/signals-quick-reference.mdx b/docs/reference/signals-quick-reference.mdx new file mode 100644 index 0000000000..084b7bd974 --- /dev/null +++ b/docs/reference/signals-quick-reference.mdx @@ -0,0 +1,200 @@ +--- +title: Signals Quick Reference +description: Compact reference for executing AdCP Signals Protocol tasks. Use this when interacting with signal agents via call_adcp_agent. +keywords: [adcp tasks, signals api, signal agent, get_signals, activate_signal, audience targeting, quick reference] +--- + +Use the `call_adcp_agent` tool to execute these tasks against any AdCP signal agent. + +## Task Overview + +| Task | Purpose | Response Time | +|------|---------|---------------| +| `get_signals` | Discover signals using natural language | ~60s | +| `activate_signal` | Activate a signal on a platform/agent | Minutes-Hours | + +## Typical Workflow + +1. **Discover signals**: `get_signals` with a natural language description of targeting needs +2. **Review options**: Evaluate signals by coverage, pricing, and deployment status +3. **Activate if needed**: `activate_signal` for signals not yet live on your platform +4. **Use in campaigns**: Reference the activation key in your media buy targeting + +--- + +## Task Reference + +### get_signals + +Discover signals based on natural language description, with deployment status across platforms. + +```json +{ + "signal_spec": "High-income households interested in luxury goods", + "deliver_to": { + "deployments": [ + { + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123" + } + ], + "countries": ["US"] + }, + "filters": { + "max_cpm": 5.0, + "catalog_types": ["marketplace"] + }, + "max_results": 5 +} +``` + +**Key fields:** +- `signal_spec` (string, required): Natural language description of desired signals +- `deliver_to` (object, required): Where signals will be used + - `deployments` (array): Target platforms/agents with `type`, `platform`/`agent_url`, and optional `account` + - `countries` (array): ISO country codes where signals will be used +- `filters` (object, optional): Filter by `catalog_types`, `data_providers`, `max_cpm`, `min_coverage_percentage` +- `max_results` (number, optional): Limit number of results + +**Deployment types:** +```json +// DSP platform +{ "type": "platform", "platform": "the-trade-desk", "account": "agency-123" } + +// Sales agent +{ "type": "agent", "agent_url": "https://salesagent.example.com" } +``` + +**Response contains:** +- `signals`: Array of matching signals with: + - `signal_agent_segment_id`: Use this in `activate_signal` + - `name`, `description`: Human-readable signal info + - `data_provider`: Source of the signal data + - `coverage_percentage`: Reach relative to agent's population + - `deployments`: Status per platform with `is_live`, `activation_key`, `estimated_activation_duration_minutes` + - `pricing`: CPM and currency + +--- + +### activate_signal + +Activate a signal for use on a specific platform or agent. + +```json +{ + "signal_agent_segment_id": "luxury_auto_intenders", + "deployments": [ + { + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd" + } + ] +} +``` + +**Key fields:** +- `signal_agent_segment_id` (string, required): From `get_signals` response +- `deployments` (array, required): Target deployment(s) with `type`, `platform`/`agent_url`, and optional `account` + +**Response contains:** +- `deployments`: Array with activation results per target + - `activation_key`: The key to use for targeting (segment ID or key-value pair) + - `deployed_at`: ISO timestamp when activation completed + - `estimated_activation_duration_minutes`: Time remaining if async +- `errors`: Any warnings or errors encountered + +--- + +## Key Concepts + +### Deployment Targets + +Signals can be activated on two types of targets: + +**DSP Platforms:** +```json +{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123" +} +``` + +**Sales Agents:** +```json +{ + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com" +} +``` + +### Activation Keys + +When signals are live, the response includes an activation key for targeting: + +**Segment ID format (typical for DSPs):** +```json +{ + "type": "segment_id", + "segment_id": "ttd_segment_12345" +} +``` + +**Key-Value format (typical for sales agents):** +```json +{ + "type": "key_value", + "key": "audience_segment", + "value": "luxury_auto_intenders" +} +``` + +### Signal Types + +- **marketplace**: Licensed from data providers (CPM pricing) +- **custom**: Built for specific principal accounts +- **owned**: Private signals from your own data (no cost) + +### Coverage Percentage + +Indicates signal reach relative to the agent's population: +- 99%: Very broad signal (matches most identifiers) +- 50%: Medium signal +- 1%: Very niche signal + +### Asynchronous Operations + +Signal activation may take time. Check the response: +- `is_live: true` + `activation_key`: Ready to use immediately +- `is_live: false` + `estimated_activation_duration_minutes`: Activation in progress + +Poll or use webhooks to check completion status. + +--- + +## Error Handling + +Common error codes: + +- `SIGNAL_AGENT_SEGMENT_NOT_FOUND`: Invalid signal_agent_segment_id +- `ACTIVATION_FAILED`: Could not activate signal +- `ALREADY_ACTIVATED`: Signal already active on target +- `DEPLOYMENT_UNAUTHORIZED`: Not authorized for platform/account +- `AGENT_NOT_FOUND`: Private agent not visible to this principal +- `AGENT_ACCESS_DENIED`: Not authorized for this signal agent + +Error responses include: +```json +{ + "errors": [ + { + "code": "DEPLOYMENT_UNAUTHORIZED", + "message": "Account not authorized for this data provider", + "field": "deployment.account", + "suggestion": "Contact your account manager to enable access" + } + ] +} +``` diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index b915b37b32..f171c2a76e 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -30,6 +30,7 @@ const { execSync } = require('child_process'); const SOURCE_DIR = path.join(__dirname, '../static/schemas/source'); const DIST_DIR = path.join(__dirname, '../dist/schemas'); const PACKAGE_JSON = path.join(__dirname, '../package.json'); +const SKILLS_DIR = path.join(__dirname, '../skills'); // Parse command line arguments const args = process.argv.slice(2); @@ -295,6 +296,79 @@ async function generateBundledSchemas(sourceDir, bundledDir, version) { return { successCount, errorCount }; } +/** + * Copy schemas from a source directory to a skill schemas directory + * Returns the count of files copied + */ +function copySchemaDir(sourceDir, targetDir) { + if (!fs.existsSync(sourceDir)) { + return 0; + } + + ensureDir(targetDir); + const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + let count = 0; + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.json')) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + fs.copyFileSync(sourcePath, targetPath); + count++; + } + } + + return count; +} + +/** + * Generate schemas for a single skill + * Returns the count of files copied, or 0 if source doesn't exist + */ +function generateSkillSchema(versionDir, version, protocol, skillName) { + const sourceDir = path.join(versionDir, protocol); + const skillDir = path.join(SKILLS_DIR, skillName, 'schemas'); + + if (!fs.existsSync(sourceDir)) { + return 0; + } + + if (fs.existsSync(skillDir)) { + fs.rmSync(skillDir, { recursive: true, force: true }); + } + ensureDir(skillDir); + + let count = copySchemaDir(sourceDir, skillDir); + count += copySchemaDir(path.join(versionDir, 'core'), path.join(skillDir, 'core')); + count += copySchemaDir(path.join(versionDir, 'enums'), path.join(skillDir, 'enums')); + + console.log(`📚 Generated skill schemas: skills/${skillName}/schemas/ (${count} files from ${version})`); + return count; +} + +/** + * Generate skill schemas from versioned dist schemas + * Copies protocol schemas to skills/{protocol}/schemas/ + */ +function generateSkillSchemas(versionDir, version) { + const skills = [ + { protocol: 'media-buy', skillName: 'adcp-media-buy' }, + { protocol: 'creative', skillName: 'adcp-creative' }, + { protocol: 'signals', skillName: 'adcp-signals' }, + ]; + + let totalCount = 0; + for (const { protocol, skillName } of skills) { + const count = generateSkillSchema(versionDir, version, protocol, skillName); + if (count === 0 && protocol === 'media-buy') { + console.log(` ⚠️ No media-buy schemas found in ${versionDir}`); + } + totalCount += count; + } + + return totalCount; +} + async function main() { const version = getVersion(); const majorVersion = getMajorVersion(version); @@ -354,6 +428,9 @@ async function main() { const latestBundledDir = path.join(latestDir, 'bundled'); await generateBundledSchemas(SOURCE_DIR, latestBundledDir, 'latest'); + // Generate skill schemas from the release version + generateSkillSchemas(versionDir, version); + // Stage the new versioned directory for git commit // This is needed for the changesets workflow to include it in the version commit console.log(`📝 Staging dist/schemas/${version}/ for git commit`); @@ -401,6 +478,9 @@ async function main() { const { successCount, errorCount } = await generateBundledSchemas(SOURCE_DIR, bundledDir, 'latest'); console.log(` ✓ Bundled ${successCount} schemas${errorCount > 0 ? ` (${errorCount} failed)` : ''}`); + // Generate skill schemas from latest + generateSkillSchemas(latestDir, 'latest'); + // Note: Version aliases (v2, v2.5, v1) are handled by HTTP middleware // No symlinks needed - the server rewrites URLs dynamically diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 6d075bec33..62938690f1 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -568,6 +568,51 @@ export const MEMBER_TOOLS: AddieTool[] = [ required: ['agent_url'], }, }, + { + name: 'call_adcp_agent', + description: + 'Execute an AdCP protocol task on an agent. This is a low-level proxy that forwards requests directly to the agent. Use adcp-media-buy, adcp-signals, or adcp-creative skills for detailed parameter schemas.', + usage_hints: 'use for "call get_products", "execute create_media_buy", "run sync_creatives", "get_signals", "build_creative", "interact with the agent directly", "use the full AdCP spec". For testing workflows, prefer test_adcp_agent instead.', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The agent URL (must be HTTPS, e.g., "https://sales.example.com")', + }, + task: { + type: 'string', + enum: [ + // Media Buy tasks + 'get_products', + 'list_authorized_properties', + 'list_creative_formats', + 'create_media_buy', + 'update_media_buy', + 'sync_creatives', + 'list_creatives', + 'get_media_buy_delivery', + // Signals tasks + 'get_signals', + 'activate_signal', + // Creative tasks + 'build_creative', + 'preview_creative', + ], + description: 'The AdCP task to execute', + }, + params: { + type: 'object', + description: 'Task parameters - structure depends on the task. See protocol skills for schemas.', + }, + auth_token: { + type: 'string', + description: 'Optional auth token. If not provided, will use saved token for this agent.', + }, + }, + required: ['agent_url', 'task'], + }, + }, // ============================================ // AGENT CONTEXT MANAGEMENT // ============================================ @@ -2187,6 +2232,87 @@ export function createMemberToolHandlers( } }); + // ============================================ + // ADCP AGENT PROXY + // ============================================ + handlers.set('call_adcp_agent', async (input) => { + const agentUrl = input.agent_url as string; + const task = input.task as string; + const params = (input.params as Record) || {}; + let authToken = input.auth_token as string | undefined; + + // Validate URL to prevent SSRF attacks + try { + const url = new URL(agentUrl); + + // Must be HTTPS + if (url.protocol !== 'https:') { + return '**Error:** Agent URL must use HTTPS protocol.'; + } + + // Block internal/private networks + const hostname = url.hostname.toLowerCase(); + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname.endsWith('.local') || + hostname.endsWith('.internal') || + hostname.startsWith('10.') || + hostname.startsWith('192.168.') || + hostname.match(/^172\.(1[6-9]|2\d|3[01])\./) || + hostname === '169.254.169.254' // AWS metadata + ) { + return '**Error:** Agent URL cannot point to internal or private networks.'; + } + } catch { + return '**Error:** Invalid agent URL format.'; + } + + // Look up saved auth token if not provided + if (!authToken && memberContext?.organization?.workos_organization_id) { + const savedContext = await agentContextDb.getByOrgAndUrl( + memberContext.organization.workos_organization_id, + agentUrl + ); + if (savedContext?.id) { + authToken = (await agentContextDb.getAuthToken(savedContext.id)) ?? undefined; + } + } + + logger.info({ agentUrl, task, hasAuth: !!authToken }, 'Addie: call_adcp_agent'); + + try { + const { AdCPClient } = await import('@adcp/client'); + const multiClient = new AdCPClient([ + { + id: 'target', + name: 'target', + agent_uri: agentUrl, + protocol: 'mcp', + ...(authToken && { auth_token: authToken }), + }, + ]); + const client = multiClient.agent('target'); + + const result = await client.executeTask(task, params); + + if (!result.success) { + // Return errors in a structured way + return `**Task failed:** \`${task}\`\n\n**Error:**\n\`\`\`json\n${JSON.stringify(result.error, null, 2)}\n\`\`\``; + } + + // Format successful response + let output = `**Task:** \`${task}\`\n**Status:** Success\n\n`; + output += `**Response:**\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\``; + + return output; + } catch (error) { + logger.error({ error, agentUrl, task }, 'Addie: call_adcp_agent failed'); + return `**Task failed:** \`${task}\`\n\n**Error:** ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }); + // ============================================ // GITHUB ISSUE DRAFTING // ============================================ diff --git a/skills/adcp-creative/SKILL.md b/skills/adcp-creative/SKILL.md new file mode 100644 index 0000000000..aff9b228aa --- /dev/null +++ b/skills/adcp-creative/SKILL.md @@ -0,0 +1,289 @@ +--- +name: adcp-creative +description: Execute AdCP Creative Protocol operations with creative agents - build creatives from briefs or existing assets, preview renderings, and discover format specifications. Use when users want to generate or transform ad creatives, preview how ads will look, or understand creative format requirements. +--- + +# AdCP Creative Protocol + +This skill enables you to execute the AdCP Creative Protocol with creative agents. Use the `call_adcp_agent` tool to send requests to creative agents. + +## Overview + +The Creative Protocol provides 3 standardized tasks for building and previewing advertising creatives: + +| Task | Purpose | Response Time | +|------|---------|---------------| +| `list_creative_formats` | View format specifications | ~1s | +| `build_creative` | Generate or transform creatives | ~30s-5m | +| `preview_creative` | Get visual previews | ~5s | + +## Typical Workflow + +1. **Discover formats**: `list_creative_formats` to see available format specs +2. **Build creative**: `build_creative` to generate or transform a manifest +3. **Preview**: `preview_creative` to see how it renders +4. **Sync**: Use `sync_creatives` (media-buy task) to traffic the creative + +--- + +## Task Reference + +### list_creative_formats + +Discover creative formats and their specifications. + +**Request:** +```json +{ + "type": "video", + "asset_types": ["image", "text"] +} +``` + +**Key fields:** +- `format_ids` (array, optional): Request specific format IDs +- `type` (string, optional): Filter by type: `video`, `display`, `audio`, `dooh` +- `asset_types` (array, optional): Filter by accepted asset types +- `max_width`, `max_height` (integer, optional): Dimension constraints +- `is_responsive` (boolean, optional): Filter for responsive formats +- `name_search` (string, optional): Search formats by name + +**Response contains:** +- `formats`: Array of format definitions with `format_id`, `name`, `type`, `assets_required`, `renders` +- `creative_agents`: Optional array of other creative agents providing additional formats + +--- + +### build_creative + +Generate a creative from scratch or transform an existing creative to a different format. + +**Pure Generation (from brief):** +```json +{ + "message": "Create a banner promoting our winter sale with a warm, inviting feel", + "target_format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250_generative" + }, + "creative_manifest": { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250_generative" + }, + "assets": { + "promoted_offerings": { + "brand_manifest": { + "url": "https://mybrand.com", + "name": "My Brand", + "colors": { "primary": "#FF5733" } + }, + "inline_offerings": [ + { + "name": "Winter Sale Collection", + "description": "50% off all winter items" + } + ] + } + } + } +} +``` + +**Transformation (resize/reformat):** +```json +{ + "message": "Adapt this leaderboard to a 300x250 banner", + "creative_manifest": { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_728x90" + }, + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.mybrand.com/leaderboard.png", + "width": 728, + "height": 90 + }, + "headline": { + "asset_type": "text", + "content": "Spring Sale - 30% Off" + } + } + }, + "target_format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + } +} +``` + +**Key fields:** +- `message` (string, optional): Natural language instructions for generation/transformation +- `creative_manifest` (object, optional): Source manifest - minimal for generation, complete for transformation +- `target_format_id` (object, required): Format to generate - `{ agent_url, id }` + +**Response contains:** +- `creative_manifest`: Complete manifest ready for `preview_creative` or `sync_creatives` + +--- + +### preview_creative + +Generate visual previews of creative manifests. + +**Single preview:** +```json +{ + "request_type": "single", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + }, + "creative_manifest": { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + }, + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.example.com/banner.png", + "width": 300, + "height": 250 + } + } + } +} +``` + +**With device variants:** +```json +{ + "request_type": "single", + "format_id": { "agent_url": "...", "id": "native_responsive" }, + "creative_manifest": { /* ... */ }, + "inputs": [ + { "name": "Desktop", "macros": { "DEVICE_TYPE": "desktop" } }, + { "name": "Mobile", "macros": { "DEVICE_TYPE": "mobile" } } + ] +} +``` + +**Batch preview (5-10x faster):** +```json +{ + "request_type": "batch", + "requests": [ + { "format_id": {...}, "creative_manifest": { /* creative 1 */ } }, + { "format_id": {...}, "creative_manifest": { /* creative 2 */ } } + ] +} +``` + +**Key fields:** +- `request_type` (string, required): `"single"` or `"batch"` +- `format_id` (object, required for single): Format identifier +- `creative_manifest` (object, required): Complete creative manifest +- `inputs` (array, optional): Generate variants with different macros/contexts +- `output_format` (string, optional): `"url"` (default) or `"html"` + +**Response contains:** +- `previews`: Array of preview objects with `preview_url` or `preview_html` +- `expires_at`: When preview URLs expire + +--- + +## Key Concepts + +### Format IDs + +All format references use structured objects: +```json +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + } +} +``` + +The `agent_url` specifies the creative agent authoritative for this format. + +### Creative Manifests + +Manifests pair format specifications with actual assets: +```json +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + }, + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.example.com/banner.png", + "width": 300, + "height": 250 + }, + "headline": { + "asset_type": "text", + "content": "Shop Now" + }, + "clickthrough_url": { + "asset_type": "url", + "url": "https://brand.com/sale" + } + } +} +``` + +### Asset Types + +Common asset types: +- `image`: Static images (JPEG, PNG, WebP) +- `video`: Video files (MP4, WebM) or VAST tags +- `audio`: Audio files (MP3, M4A) or DAAST tags +- `text`: Headlines, descriptions, CTAs +- `html`: HTML5 creatives or third-party tags +- `javascript`: JavaScript tags +- `url`: Tracking pixels, clickthrough URLs + +### Brand Manifest + +For generative creatives, provide brand context: +```json +{ + "brand_manifest": { + "url": "https://brand.com", + "name": "Brand Name", + "colors": { "primary": "#FF0000", "secondary": "#0000FF" } + } +} +``` + +### Generative vs Transformation + +- **Pure Generation**: Minimal manifest with `promoted_offerings` in assets. Creative agent generates all output assets from scratch. +- **Transformation**: Complete manifest with existing assets. Creative agent adapts to target format, following `message` guidance. + +--- + +## Error Handling + +Common error patterns: + +- **400 Bad Request**: Invalid manifest or format_id +- **404 Not Found**: Format not supported by this agent +- **422 Validation Error**: Manifest doesn't match format requirements + +Error responses include: +```json +{ + "error": { + "code": "INVALID_FORMAT_ID", + "message": "format_id must be a structured object with 'agent_url' and 'id' fields" + } +} +``` diff --git a/skills/adcp-media-buy/SKILL.md b/skills/adcp-media-buy/SKILL.md new file mode 100644 index 0000000000..80e3072609 --- /dev/null +++ b/skills/adcp-media-buy/SKILL.md @@ -0,0 +1,345 @@ +--- +name: adcp-media-buy +description: Execute AdCP Media Buy Protocol operations with sales agents - discover advertising products, create and manage campaigns, sync creatives, and track delivery. Use when users want to buy advertising, create media buys, interact with ad sales agents, or test advertising APIs. +--- + +# AdCP Media Buy Protocol + +This skill enables you to execute the AdCP Media Buy Protocol with sales agents. Use the `call_adcp_agent` tool to send requests to sales agents. + +## Overview + +The Media Buy Protocol provides 8 standardized tasks for managing advertising campaigns: + +| Task | Purpose | Response Time | +|------|---------|---------------| +| `get_products` | Discover inventory using natural language | ~60s | +| `list_authorized_properties` | See publisher properties | ~1s | +| `list_creative_formats` | View creative specifications | ~1s | +| `create_media_buy` | Create campaigns | Minutes-Days | +| `update_media_buy` | Modify campaigns | Minutes-Days | +| `sync_creatives` | Upload creative assets | Minutes-Days | +| `list_creatives` | Query creative library | ~1s | +| `get_media_buy_delivery` | Get performance data | ~60s | + +## Typical Workflow + +1. **Discover products**: `get_products` with a natural language brief +2. **Review formats**: `list_creative_formats` to understand creative requirements +3. **Create campaign**: `create_media_buy` with selected products and budget +4. **Upload creatives**: `sync_creatives` to add creative assets +5. **Monitor delivery**: `get_media_buy_delivery` to track performance + +--- + +## Task Reference + +### get_products + +Discover advertising products using natural language briefs. + +**Request:** +```json +{ + "brief": "Looking for premium video inventory for a tech brand targeting developers", + "brand_manifest": { + "url": "https://example.com" + }, + "filters": { + "channels": ["video", "ctv"], + "budget_range": { "min": 5000, "max": 50000 } + } +} +``` + +**Key fields:** +- `brief` (string): Natural language description of campaign requirements +- `brand_manifest` (object): Brand context - can be `{ "url": "https://..." }` or inline manifest +- `filters` (object, optional): Filter by channels, budget, delivery_type, format_types + +**Response contains:** +- `products`: Array of matching products with `product_id`, `name`, `description`, `pricing_options` +- Each product includes `format_ids` (supported creative formats) and `targeting` (available targeting) + +--- + +### list_authorized_properties + +Get the list of publisher properties this agent can sell. + +**Request:** +```json +{} +``` + +No parameters required. + +**Response contains:** +- `publisher_domains`: Array of domain strings the agent is authorized to sell + +--- + +### list_creative_formats + +View supported creative specifications. + +**Request:** +```json +{ + "format_types": ["video", "display"] +} +``` + +**Key fields:** +- `format_types` (array, optional): Filter to specific format categories + +**Response contains:** +- `formats`: Array of format specifications with dimensions, requirements, and asset schemas + +--- + +### create_media_buy + +Create an advertising campaign from selected products. + +**Request:** +```json +{ + "buyer_ref": "campaign-2024-q1-001", + "brand_manifest": { + "url": "https://acme.com", + "name": "Acme Corporation" + }, + "packages": [ + { + "buyer_ref": "pkg-video-001", + "product_id": "premium_video_30s", + "pricing_option_id": "cpm-standard", + "budget": 10000 + } + ], + "start_time": { + "type": "asap" + }, + "end_time": "2024-03-31T23:59:59Z" +} +``` + +**Key fields:** +- `buyer_ref` (string, required): Your unique identifier for this campaign +- `brand_manifest` (object, required): Brand identity - URL or inline manifest +- `packages` (array, required): Products to purchase, each with: + - `buyer_ref`: Your identifier for this package + - `product_id`: From `get_products` response + - `pricing_option_id`: From product's `pricing_options` + - `budget`: Amount in dollars + - `bid_price`: Required for auction pricing + - `targeting_overlay`: Additional targeting constraints + - `creative_ids` or `creatives`: Creative assignments +- `start_time` (object, required): `{ "type": "asap" }` or `{ "type": "scheduled", "datetime": "..." }` +- `end_time` (string, required): ISO 8601 datetime + +**Response contains:** +- `media_buy_id`: The created campaign identifier +- `status`: Current state (often `pending` for async approval) +- `packages`: Created packages with their IDs + +--- + +### update_media_buy + +Modify an existing campaign. + +**Request:** +```json +{ + "media_buy_id": "mb_abc123", + "updates": { + "budget_change": 5000, + "end_time": "2024-04-30T23:59:59Z", + "status": "paused" + } +} +``` + +**Key fields:** +- `media_buy_id` (string, required): The campaign to update +- `updates` (object): Changes to apply - budget_change, end_time, status, targeting, etc. + +--- + +### sync_creatives + +Upload and manage creative assets. + +**Request:** +```json +{ + "creatives": [ + { + "creative_id": "hero_video_30s", + "name": "Brand Hero Video", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_standard_30s" + }, + "assets": { + "video": { + "url": "https://cdn.example.com/hero.mp4", + "width": 1920, + "height": 1080, + "duration_ms": 30000 + } + } + } + ], + "assignments": { + "hero_video_30s": ["pkg_001", "pkg_002"] + } +} +``` + +**Key fields:** +- `creatives` (array, required): Creative assets to sync + - `creative_id`: Your unique identifier + - `format_id`: Object with `agent_url` and `id` from format specifications + - `assets`: Asset content (video, image, html, etc.) +- `assignments` (object, optional): Map creative_id to package IDs +- `dry_run` (boolean): Preview changes without applying +- `delete_missing` (boolean): Archive creatives not in this sync + +--- + +### list_creatives + +Query the creative library with filtering. + +**Request:** +```json +{ + "filters": { + "status": ["active"], + "format_types": ["video"] + }, + "limit": 20 +} +``` + +--- + +### get_media_buy_delivery + +Retrieve performance metrics for a campaign. + +**Request:** +```json +{ + "media_buy_id": "mb_abc123", + "granularity": "daily", + "date_range": { + "start": "2024-01-01", + "end": "2024-01-31" + } +} +``` + +**Response contains:** +- `delivery`: Aggregated metrics (impressions, spend, clicks, etc.) +- `by_package`: Breakdown by package +- `timeseries`: Data points over time if granularity specified + +--- + +## Key Concepts + +### Brand Manifest + +Brand context can be provided in two ways: + +1. **URL reference** (recommended): +```json +{ + "brand_manifest": { + "url": "https://brand.com" + } +} +``` + +2. **Inline manifest**: +```json +{ + "brand_manifest": { + "name": "Brand Name", + "url": "https://brand.com", + "tagline": "Brand tagline", + "colors": { "primary": "#FF0000" } + } +} +``` + +### Format IDs + +Creative format identifiers are structured objects: +```json +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + } +} +``` + +The `agent_url` specifies which creative agent defines the format. Use `https://creative.adcontextprotocol.org` for standard IAB formats. + +### Pricing Options + +Products include `pricing_options` array. Each option has: +- `pricing_option_id`: Use this in `create_media_buy` +- `pricing_model`: "cpm", "cpm-auction", "flat-fee", etc. +- `price`: Base price (for fixed pricing) +- `floor`: Minimum bid (for auction) + +For auction pricing, include `bid_price` in your package. + +### Asynchronous Operations + +Operations like `create_media_buy` and `sync_creatives` may require human approval. The response includes: +- `status: "pending"` - Operation awaiting approval +- `task_id` - For tracking async progress + +Poll or use webhooks to check completion status. + +--- + +## Error Handling + +Common error patterns: + +- **400 Bad Request**: Invalid parameters - check required fields +- **401 Unauthorized**: Invalid or missing authentication token +- **404 Not Found**: Invalid product_id, media_buy_id, or creative_id +- **422 Validation Error**: Schema validation failure - check field types + +Error responses include: +```json +{ + "errors": [ + { + "code": "VALIDATION_ERROR", + "message": "budget must be greater than 0", + "field": "packages[0].budget" + } + ] +} +``` + +--- + +## Testing Mode + +For testing without real transactions, agents may support: +- `X-Dry-Run: true` header - Preview without side effects +- Test products with `test: true` flag +- Sandbox environments + +Ask the agent about testing capabilities before creating real campaigns. diff --git a/skills/adcp-signals/SKILL.md b/skills/adcp-signals/SKILL.md new file mode 100644 index 0000000000..d35159dfd2 --- /dev/null +++ b/skills/adcp-signals/SKILL.md @@ -0,0 +1,205 @@ +--- +name: adcp-signals +description: Execute AdCP Signals Protocol operations with signal agents - discover audience signals using natural language and activate them on DSPs or sales agents. Use when users want to find targeting data, activate audience segments, or work with signal providers. +--- + +# AdCP Signals Protocol + +This skill enables you to execute the AdCP Signals Protocol with signal agents. Use the `call_adcp_agent` tool to send requests to signal agents. + +## Overview + +The Signals Protocol provides 2 standardized tasks for discovering and activating targeting data: + +| Task | Purpose | Response Time | +|------|---------|---------------| +| `get_signals` | Discover signals using natural language | ~60s | +| `activate_signal` | Activate a signal on a platform/agent | Minutes-Hours | + +## Typical Workflow + +1. **Discover signals**: `get_signals` with a natural language description of targeting needs +2. **Review options**: Evaluate signals by coverage, pricing, and deployment status +3. **Activate if needed**: `activate_signal` for signals not yet live on your platform +4. **Use in campaigns**: Reference the activation key in your media buy targeting + +--- + +## Task Reference + +### get_signals + +Discover signals based on natural language description, with deployment status across platforms. + +**Request:** +```json +{ + "signal_spec": "High-income households interested in luxury goods", + "deliver_to": { + "deployments": [ + { + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123" + } + ], + "countries": ["US"] + }, + "filters": { + "max_cpm": 5.0, + "catalog_types": ["marketplace"] + }, + "max_results": 5 +} +``` + +**Key fields:** +- `signal_spec` (string, required): Natural language description of desired signals +- `deliver_to` (object, required): Where signals will be used + - `deployments` (array): Target platforms/agents with `type`, `platform`/`agent_url`, and optional `account` + - `countries` (array): ISO country codes where signals will be used +- `filters` (object, optional): Filter by `catalog_types`, `data_providers`, `max_cpm`, `min_coverage_percentage` +- `max_results` (number, optional): Limit number of results + +**Deployment types:** +```json +// DSP platform +{ "type": "platform", "platform": "the-trade-desk", "account": "agency-123" } + +// Sales agent +{ "type": "agent", "agent_url": "https://salesagent.example.com" } +``` + +**Response contains:** +- `signals`: Array of matching signals with: + - `signal_agent_segment_id`: Use this in `activate_signal` + - `name`, `description`: Human-readable signal info + - `data_provider`: Source of the signal data + - `coverage_percentage`: Reach relative to agent's population + - `deployments`: Status per platform with `is_live`, `activation_key`, `estimated_activation_duration_minutes` + - `pricing`: CPM and currency + +--- + +### activate_signal + +Activate a signal for use on a specific platform or agent. + +**Request:** +```json +{ + "signal_agent_segment_id": "luxury_auto_intenders", + "deployments": [ + { + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd" + } + ] +} +``` + +**Key fields:** +- `signal_agent_segment_id` (string, required): From `get_signals` response +- `deployments` (array, required): Target deployment(s) with `type`, `platform`/`agent_url`, and optional `account` + +**Response contains:** +- `deployments`: Array with activation results per target + - `activation_key`: The key to use for targeting (segment ID or key-value pair) + - `deployed_at`: ISO timestamp when activation completed + - `estimated_activation_duration_minutes`: Time remaining if async +- `errors`: Any warnings or errors encountered + +--- + +## Key Concepts + +### Deployment Targets + +Signals can be activated on two types of targets: + +**DSP Platforms:** +```json +{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123" +} +``` + +**Sales Agents:** +```json +{ + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com" +} +``` + +### Activation Keys + +When signals are live, the response includes an activation key for targeting: + +**Segment ID format (typical for DSPs):** +```json +{ + "type": "segment_id", + "segment_id": "ttd_segment_12345" +} +``` + +**Key-Value format (typical for sales agents):** +```json +{ + "type": "key_value", + "key": "audience_segment", + "value": "luxury_auto_intenders" +} +``` + +### Signal Types + +- **marketplace**: Licensed from data providers (CPM pricing) +- **custom**: Built for specific principal accounts +- **owned**: Private signals from your own data (no cost) + +### Coverage Percentage + +Indicates signal reach relative to the agent's population: +- 99%: Very broad signal (matches most identifiers) +- 50%: Medium signal +- 1%: Very niche signal + +### Asynchronous Operations + +Signal activation may take time. Check the response: +- `is_live: true` + `activation_key`: Ready to use immediately +- `is_live: false` + `estimated_activation_duration_minutes`: Activation in progress + +Poll or use webhooks to check completion status. + +--- + +## Error Handling + +Common error codes: + +- `SIGNAL_AGENT_SEGMENT_NOT_FOUND`: Invalid signal_agent_segment_id +- `ACTIVATION_FAILED`: Could not activate signal +- `ALREADY_ACTIVATED`: Signal already active on target +- `DEPLOYMENT_UNAUTHORIZED`: Not authorized for platform/account +- `AGENT_NOT_FOUND`: Private agent not visible to this principal +- `AGENT_ACCESS_DENIED`: Not authorized for this signal agent + +Error responses include: +```json +{ + "errors": [ + { + "code": "DEPLOYMENT_UNAUTHORIZED", + "message": "Account not authorized for this data provider", + "field": "deployment.account", + "suggestion": "Contact your account manager to enable access" + } + ] +} +``` From a9e264708667f0ca1be7b5d34aab78d2f95b6b04 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 13 Jan 2026 12:01:20 -0500 Subject: [PATCH 71/77] feat: sync working group posts to Slack channels (#751) Automatically share published working group posts to their linked Slack channels. When a post is published, a formatted message is sent to the working group's Slack channel (if configured). - Add notifyWorkingGroupSlackChannel() for posting to specific channels - Add notifyPublishedPost() wrapper that checks privacy and channel config - Update committees.ts routes to use new notification function - Add notifications to content.ts for propose/approve endpoints - Skip members-only posts to respect privacy - Use Block Kit for rich message formatting Closes #719 Co-authored-by: Claude Opus 4.5 --- .changeset/eleven-wolves-return.md | 2 + server/src/notifications/slack.ts | 135 +++++++++++++++++++++++++++++ server/src/routes/committees.ts | 49 +++++++---- server/src/routes/content.ts | 50 +++++++++-- 4 files changed, 213 insertions(+), 23 deletions(-) create mode 100644 .changeset/eleven-wolves-return.md 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/server/src/notifications/slack.ts b/server/src/notifications/slack.ts index 81cc79b88c..b29126cfb0 100644 --- a/server/src/notifications/slack.ts +++ b/server/src/notifications/slack.ts @@ -3,8 +3,11 @@ */ import { logger } from '../logger.js'; +import { sendChannelMessage, isSlackConfigured } from '../slack/client.js'; +import type { SlackBlockMessage } from '../slack/types.js'; const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL; +const APP_URL = process.env.APP_URL || 'https://agenticadvertising.org'; interface SlackMessage { text: string; @@ -381,3 +384,135 @@ export async function notifyWorkingGroupPost(data: { ], }); } + +/** + * Send a notification to a working group's Slack channel about a published post. + * Uses the chapter bot to post directly to the channel. + */ +export async function notifyWorkingGroupSlackChannel(data: { + slackChannelId: string; + workingGroupName: string; + workingGroupSlug: string; + postTitle: string; + postSlug: string; + authorName: string; + contentType: 'article' | 'link'; + excerpt?: string; + externalUrl?: string; + category?: string; +}): Promise { + if (!isSlackConfigured()) { + logger.debug('Slack not configured, skipping channel notification'); + return false; + } + + const emoji = data.contentType === 'link' ? '🔗' : '📝'; + const headerText = data.contentType === 'link' ? 'New Link Shared' : 'New Post Published'; + const postUrl = `${APP_URL}/working-groups/${data.workingGroupSlug}#post-${data.postSlug}`; + + // Build message blocks + const message: SlackBlockMessage = { + text: `${emoji} ${headerText} in ${data.workingGroupName}: ${data.postTitle}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text' as const, + text: `${emoji} ${headerText}`, + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `*<${postUrl}|${data.postTitle}>*${data.excerpt ? `\n${data.excerpt}` : ''}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `Posted by ${data.authorName}${data.category ? ` • ${data.category}` : ''}`, + }, + }, + // Add external URL for link posts + ...(data.contentType === 'link' && data.externalUrl ? [{ + type: 'section', + text: { + type: 'mrkdwn' as const, + text: `🔗 ${data.externalUrl}`, + }, + }] : []), + ], + }; + + try { + const result = await sendChannelMessage(data.slackChannelId, message); + if (result.ok) { + logger.info( + { channelId: data.slackChannelId, postSlug: data.postSlug }, + 'Sent working group post notification to Slack channel' + ); + return true; + } else { + logger.warn( + { channelId: data.slackChannelId, error: result.error }, + 'Failed to send working group post notification' + ); + return false; + } + } catch (error) { + logger.error({ error, channelId: data.slackChannelId }, 'Error sending Slack channel notification'); + return false; + } +} + +/** + * Notify a working group's Slack channel about a published post. + * Checks if the post is members-only (skips) and if the working group has a channel configured. + * + * @param slackChannelId - The Slack channel ID (required) + */ +export async function notifyPublishedPost(data: { + slackChannelId?: string; + workingGroupName: string; + workingGroupSlug: string; + postTitle: string; + postSlug: string; + authorName: string; + contentType: 'article' | 'link'; + excerpt?: string; + externalUrl?: string; + category?: string; + isMembersOnly: boolean; +}): Promise { + // Skip members-only posts - don't share private content to channels + if (data.isMembersOnly) { + logger.debug({ postSlug: data.postSlug }, 'Skipping Slack notification for members-only post'); + return; + } + + // Skip if no channel ID provided + if (!data.slackChannelId) { + logger.debug( + { workingGroupSlug: data.workingGroupSlug }, + 'No Slack channel configured for working group, skipping notification' + ); + return; + } + + // Send notification (fire-and-forget, errors are logged internally) + await notifyWorkingGroupSlackChannel({ + slackChannelId: data.slackChannelId, + workingGroupName: data.workingGroupName, + workingGroupSlug: data.workingGroupSlug, + postTitle: data.postTitle, + postSlug: data.postSlug, + authorName: data.authorName, + contentType: data.contentType, + excerpt: data.excerpt, + externalUrl: data.externalUrl, + category: data.category, + }); +} diff --git a/server/src/routes/committees.ts b/server/src/routes/committees.ts index a29d879541..3772b6da7b 100644 --- a/server/src/routes/committees.ts +++ b/server/src/routes/committees.ts @@ -14,7 +14,7 @@ import { WorkingGroupDatabase } from "../db/working-group-db.js"; import { eventsDb } from "../db/events-db.js"; import { invalidateMemberContextCache } from "../addie/index.js"; import { syncWorkingGroupMembersFromSlack, syncAllWorkingGroupMembersFromSlack } from "../slack/sync.js"; -import { notifyWorkingGroupPost } from "../notifications/slack.js"; +import { notifyPublishedPost } from "../notifications/slack.js"; import { decodeHtmlEntities } from "../utils/html-entities.js"; import { reindexDocument } from "../addie/jobs/committee-document-indexer.js"; import { createChannel, setChannelPurpose } from "../slack/client.js"; @@ -1255,19 +1255,22 @@ export function createCommitteeRouters(): { ] ); - if (!finalMembersOnly) { - notifyWorkingGroupPost({ - workingGroupName: group.name, - workingGroupSlug: slug, - postTitle: title, - postSlug: post_slug, - authorName, - contentType: content_type || 'article', - category: category || undefined, - }).catch(err => { - logger.warn({ err }, 'Failed to send Slack notification for working group post'); - }); - } + // Send Slack notification to the working group's channel + notifyPublishedPost({ + slackChannelId: group.slack_channel_id ?? undefined, + workingGroupName: group.name, + workingGroupSlug: slug, + postTitle: title, + postSlug: post_slug, + authorName, + contentType: content_type || 'article', + excerpt: excerpt || undefined, + externalUrl: external_url || undefined, + category: category || undefined, + isMembersOnly: finalMembersOnly, + }).catch(err => { + logger.warn({ err }, 'Failed to send Slack channel notification for working group post'); + }); res.status(201).json({ post: result.rows[0] }); } catch (error) { @@ -1975,17 +1978,22 @@ export function createCommitteeRouters(): { const createdPost = result.rows[0]; + // Send Slack notification to the working group's channel if (status === 'published') { - notifyWorkingGroupPost({ + notifyPublishedPost({ + slackChannelId: group.slack_channel_id ?? undefined, workingGroupName: group.name, workingGroupSlug: slug, postTitle: title, postSlug: post_slug, authorName: authorNameFinal, contentType: content_type || 'article', + excerpt: excerpt || undefined, + externalUrl: external_url || undefined, category: category || undefined, + isMembersOnly: is_members_only || false, }).catch(err => { - logger.warn({ err }, 'Failed to send Slack notification for working group post'); + logger.warn({ err }, 'Failed to send Slack channel notification for working group post'); }); } @@ -2099,17 +2107,22 @@ export function createCommitteeRouters(): { const updatedPost = result.rows[0]; + // Send Slack notification when post transitions to published if (willBePublished && !wasPublished) { - notifyWorkingGroupPost({ + notifyPublishedPost({ + slackChannelId: group.slack_channel_id ?? undefined, workingGroupName: group.name, workingGroupSlug: slug, postTitle: updatedPost.title, postSlug: updatedPost.slug, authorName: updatedPost.author_name || 'Unknown', contentType: updatedPost.content_type || 'article', + excerpt: updatedPost.excerpt || undefined, + externalUrl: updatedPost.external_url || undefined, category: updatedPost.category || undefined, + isMembersOnly: updatedPost.is_members_only || false, }).catch(err => { - logger.warn({ err }, 'Failed to send Slack notification for working group post'); + logger.warn({ err }, 'Failed to send Slack channel notification for working group post'); }); } diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts index c6adc382c2..5563baf164 100644 --- a/server/src/routes/content.ts +++ b/server/src/routes/content.ts @@ -14,6 +14,7 @@ import { requireAuth } from '../middleware/auth.js'; import { getPool } from '../db/client.js'; import { isWebUserAdmin } from '../addie/mcp/admin-tools.js'; import { sendChannelMessage } from '../slack/client.js'; +import { notifyPublishedPost } from '../notifications/slack.js'; const logger = createLogger('content-routes'); @@ -248,7 +249,7 @@ export function createContentRouter(): Router { // Resolve the collection (working group) const committeeResult = await pool.query( - `SELECT id, accepts_public_submissions FROM working_groups WHERE slug = $1`, + `SELECT id, name, accepts_public_submissions, slack_channel_id FROM working_groups WHERE slug = $1`, [committeeSlug] ); @@ -259,8 +260,11 @@ export function createContentRouter(): Router { }); } - const committeeId = committeeResult.rows[0].id as string; - const acceptsPublicSubmissions = committeeResult.rows[0].accepts_public_submissions; + const committee = committeeResult.rows[0]; + const committeeId = committee.id as string; + const committeeName = committee.name as string; + const committeeSlackChannelId = committee.slack_channel_id as string | null; + const acceptsPublicSubmissions = committee.accepts_public_submissions; // Check if user can submit to this collection const userIsLead = await isCommitteeLead(committeeId, user.id); @@ -352,6 +356,23 @@ export function createContentRouter(): Router { notifyWorkingGroupOfPendingContent(committeeId, perspective, authorName).catch(err => { logger.error({ err, perspectiveId: perspective.id, committeeId, authorName }, 'Failed to send content notification'); }); + } else if (status === 'published') { + // Send Slack notification to the working group's channel + notifyPublishedPost({ + slackChannelId: committeeSlackChannelId ?? undefined, + workingGroupName: committeeName, + workingGroupSlug: committeeSlug, + postTitle: title, + postSlug: perspective.slug, + authorName, + contentType: content_type, + excerpt: excerpt || undefined, + externalUrl: external_url || undefined, + category: category || undefined, + isMembersOnly: false, // Content proposed through unified system is public + }).catch(err => { + logger.warn({ err }, 'Failed to send Slack channel notification for proposed content'); + }); } const message = canPublishDirectly @@ -490,9 +511,9 @@ export function createContentRouter(): Router { const { publish_immediately = true } = req.body; const pool = getPool(); - // Get the content + // Get the content with working group details const contentResult = await pool.query( - `SELECT p.*, wg.slug as committee_slug + `SELECT p.*, wg.slug as committee_slug, wg.name as committee_name, wg.slack_channel_id FROM perspectives p LEFT JOIN working_groups wg ON wg.id = p.working_group_id WHERE p.id = $1`, @@ -547,6 +568,25 @@ export function createContentRouter(): Router { committeeSlug: content.committee_slug, }, 'Content approved'); + // Send Slack notification when content is published + if (newStatus === 'published' && content.committee_slug) { + notifyPublishedPost({ + slackChannelId: content.slack_channel_id ?? undefined, + workingGroupName: content.committee_name, + workingGroupSlug: content.committee_slug, + postTitle: content.title, + postSlug: content.slug, + authorName: content.author_name || 'Unknown', + contentType: content.content_type || 'article', + excerpt: content.excerpt || undefined, + externalUrl: content.external_url || undefined, + category: content.category || undefined, + isMembersOnly: content.is_members_only || false, + }).catch(err => { + logger.warn({ err }, 'Failed to send Slack channel notification for approved content'); + }); + } + res.json({ success: true, status: newStatus, From 8b7d9a953a33bcf2cc1a7c6f0d2b45def32e12ff Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 13 Jan 2026 13:41:28 -0500 Subject: [PATCH 72/77] feat: make Addie proactively read shared files (#753) Previously when users shared text files in Slack, Addie would only see file metadata and wait for an explicit request to read the file. This was frustrating UX - users expected Addie to see what they shared. Changes: - Update read_slack_file tool description to instruct Claude to proactively read files when shared, without waiting to be asked - Add File Handling section to Addie's system prompt with explicit guidance on proactive file reading - Include guidance to inform users if file content is truncated Co-authored-by: Claude Opus 4.5 --- .changeset/smooth-rice-chew.md | 2 ++ server/src/addie/mcp/url-tools.ts | 4 ++-- server/src/addie/prompts.ts | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/smooth-rice-chew.md 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/server/src/addie/mcp/url-tools.ts b/server/src/addie/mcp/url-tools.ts index 887e9ac787..fde6589409 100644 --- a/server/src/addie/mcp/url-tools.ts +++ b/server/src/addie/mcp/url-tools.ts @@ -118,8 +118,8 @@ export const URL_TOOLS: AddieTool[] = [ { name: 'read_slack_file', description: - 'Download and read a file that was shared in Slack. Use this when a user shares a file (PDF, document, text file, etc.) and asks about its contents. Provide the file URL from the shared file info.', - usage_hints: 'use when user shares a file in Slack and asks "what is in this file?", "can you read this?"', + 'Download and read a file that was shared in Slack. PROACTIVELY use this whenever a user shares a file (PDF, document, text file, image, etc.) - don\'t wait to be asked. Users expect you to see what they shared. Provide the file URL from the shared file info.', + usage_hints: 'use IMMEDIATELY when you see "[Shared files]" in a message - read the file proactively', input_schema: { type: 'object', properties: { diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts index daa6c3258a..b633578279 100644 --- a/server/src/addie/prompts.ts +++ b/server/src/addie/prompts.ts @@ -212,6 +212,9 @@ When users ask about finding help, use search_members to find relevant organizat IMPORTANT: Never tell users to use Slack slash commands - the AAO bot commands are deprecated. Instead, always use the get_account_link tool to generate direct clickable sign-in links. +**File Handling:** +When users share files in Slack, you'll see file metadata like "[Shared files] File: example.txt | Type: TXT | Size: 5 KB | Link: https://...". For text-based files (.txt, .md, .json, .csv, .yaml, .ts, .py, .js, etc.), **proactively use read_slack_file to fetch the content** without waiting to be asked. Users expect you to see what they shared. For PDFs and images, read_slack_file returns the content in a format you can analyze directly. If the file is truncated or cannot be fully read, let the user know. + **GitHub Issue Drafting:** - draft_github_issue: Draft a GitHub issue and generate a pre-filled URL for users to create it From 1b21992df7e8ce0c64d803a582e0dc59fb4a9060 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 13 Jan 2026 13:49:23 -0500 Subject: [PATCH 73/77] feat: consolidate agent testing tools into probe_adcp_agent (#752) * feat: consolidate agent testing tools into probe_adcp_agent Replaces check_agent_health and get_agent_capabilities with a single probe_adcp_agent tool that always validates connectivity first, then retrieves capabilities. This addresses user confusion where health check and capabilities returned inconsistent results. - Removes check_agent_health tool (HTTP card validation only) - Removes get_agent_capabilities tool (registry lookup, often cached) - Adds probe_adcp_agent tool combining health + capabilities - Adds error handling for health check API failures - Adds MCP client configuration section to auth docs Addresses Slack feedback about auth confusion and tool inconsistency. Co-Authored-By: Claude Opus 4.5 * chore: add changeset description and clarifying comment - Add description to empty changeset for changelog history - Clarify graceful degradation behavior in probe_adcp_agent Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/green-cooks-double.md | 4 + docs/reference/authentication.mdx | 64 +++++++++ server/src/addie/mcp/member-tools.ts | 208 ++++++++++----------------- tests/addie/member-tools.test.ts | 13 +- 4 files changed, 148 insertions(+), 141 deletions(-) create mode 100644 .changeset/green-cooks-double.md 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/docs/reference/authentication.mdx b/docs/reference/authentication.mdx index 44402d4f8e..0f1e4aafa4 100644 --- a/docs/reference/authentication.mdx +++ b/docs/reference/authentication.mdx @@ -95,6 +95,70 @@ Both MCP and A2A protocols use the same authentication header. Configure your cl The client library handles adding the `Authorization: Bearer ` header to requests. +## MCP Client Configuration + +When using the MCP protocol, authentication is handled by the transport layer, not by adding HTTP headers manually. + +### Using MCP Client Libraries + +The recommended approach is to use an MCP client library: + + + +```typescript TypeScript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const transport = new StreamableHTTPClientTransport( + new URL('https://test-agent.adcontextprotocol.org/mcp'), + { + requestInit: { + headers: { + 'Authorization': 'Bearer YOUR_TOKEN_HERE' + } + } + } +); + +const client = new Client({ name: 'my-client', version: '1.0.0' }); +await client.connect(transport); +``` + +```python Python +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +async with streamablehttp_client( + "https://test-agent.adcontextprotocol.org/mcp", + headers={"Authorization": "Bearer YOUR_TOKEN_HERE"} +) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() +``` + + + +### Common Mistake: Raw HTTP Headers + +A common mistake is trying to add authentication headers to raw HTTP requests: + +```http +# This won't work for MCP endpoints +GET /mcp HTTP/1.1 +Authorization: Bearer YOUR_TOKEN +``` + +MCP uses a streaming protocol over HTTP. The authentication must be configured in the MCP client transport layer, which handles the protocol negotiation and message framing. + +### Troubleshooting Authentication + +If you're getting "authentication required" errors: + +1. **Verify you're using an MCP client library** - not making raw HTTP calls +2. **Check the token format** - should be passed to the transport configuration +3. **Test with the public test agent** - verify your setup works before testing custom agents +4. **Check protocol version** - ensure client and server protocol versions are compatible + ## Obtaining Credentials ### Account Setup Process diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 62938690f1..5f2dc73c07 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -505,14 +505,14 @@ export const MEMBER_TOOLS: AddieTool[] = [ // AGENT TESTING & COMPLIANCE // ============================================ { - name: 'check_agent_health', + name: 'probe_adcp_agent', description: - 'Check if an AdCP agent is online and responding. Tests the agent\'s endpoint and returns health status, response time, and available tools. Use this when users want to verify their agent is working before adding it to their profile or authorizing it.', - usage_hints: 'use for "is my agent working?", "test my agent endpoint"', + 'Check if an AdCP agent is online and discover its capabilities. Always validates connectivity first, then retrieves available tools and operations. Use this as the primary tool for investigating agents - it combines health checking with capability discovery.', + usage_hints: 'use for "is this agent working?", "what can this agent do?", "check my agent", "probe agent", "test connectivity"', input_schema: { type: 'object', properties: { - agent_url: { type: 'string', description: 'Agent URL' }, + agent_url: { type: 'string', description: 'The agent URL to probe' }, }, required: ['agent_url'], }, @@ -531,19 +531,6 @@ export const MEMBER_TOOLS: AddieTool[] = [ required: ['domain', 'agent_url'], }, }, - { - name: 'get_agent_capabilities', - description: - 'Get detailed capabilities of an AdCP agent including available tools and supported operations. Use this to help users understand what an agent can do before using it.', - usage_hints: 'use for "what can this agent do?", inspecting agent tools', - input_schema: { - type: 'object', - properties: { - agent_url: { type: 'string', description: 'Agent URL' }, - }, - required: ['agent_url'], - }, - }, { name: 'test_adcp_agent', description: @@ -1889,74 +1876,106 @@ export function createMemberToolHandlers( // ============================================ // AGENT TESTING & COMPLIANCE // ============================================ - handlers.set('check_agent_health', async (input) => { + handlers.set('probe_adcp_agent', async (input) => { const agentUrl = input.agent_url as string; - // Use the validate-cards endpoint which checks agent card + health - const result = await callApi('POST', '/api/adagents/validate-cards', memberContext, { + // Step 1: Health check (always do this first) + const healthResult = await callApi('POST', '/api/adagents/validate-cards', memberContext, { agent_urls: [agentUrl], }); - if (!result.ok) { - return `Failed to check agent health: ${result.error}`; + if (!healthResult.ok) { + return `## Agent Probe Failed\n\nUnable to probe agent at ${agentUrl}.\n\n**Error:** ${healthResult.error || 'Unknown error occurred while checking agent health.'}`; } - const data = result.data as { + const healthData = healthResult.data as { agent_cards: Array<{ agent_url: string; valid: boolean; - errors: string[]; + errors?: string[]; status_code?: number; response_time_ms?: number; - card_data?: { - name?: string; - description?: string; - protocol?: string; - }; + card_data?: { name?: string; description?: string; protocol?: string }; card_endpoint?: string; }>; }; - if (!data.agent_cards || data.agent_cards.length === 0) { - return `No response received for agent ${agentUrl}`; - } + const card = healthData?.agent_cards?.[0]; + const isHealthy = card?.valid === true; + + // Step 2: Try capability discovery (non-blocking - show health status regardless of outcome) + const encodedUrl = encodeURIComponent(agentUrl); + const capResult = await callApi('GET', `/api/registry/agents?url=${encodedUrl}&capabilities=true`, memberContext); + const capData = capResult.data as { + agents: Array<{ + name: string; + url: string; + type: string; + protocol: string; + description?: string; + capabilities?: { + tools_count: number; + tools: Array<{ name: string; description?: string }>; + standard_operations?: string[]; + }; + }>; + }; + const agent = capData?.agents?.[0]; - const card = data.agent_cards[0]; - let response = `## Agent Health Check: ${agentUrl}\n\n`; + // Step 3: Format unified response + let response = `## Agent Probe: ${agent?.name || agentUrl}\n\n`; - if (card.valid) { - response += `**Status:** ✅ Online and responding\n`; + // Health section + response += `### Connectivity\n`; + if (isHealthy) { + response += `**Status:** ✅ Online\n`; if (card.response_time_ms) { response += `**Response Time:** ${card.response_time_ms}ms\n`; } - if (card.card_data?.name) { - response += `**Name:** ${card.card_data.name}\n`; - } - if (card.card_data?.description) { - response += `**Description:** ${card.card_data.description}\n`; - } if (card.card_data?.protocol) { response += `**Protocol:** ${card.card_data.protocol}\n`; } - if (card.card_endpoint) { - response += `**Card Endpoint:** ${card.card_endpoint}\n`; - } - response += `\n✅ This agent is properly configured and ready to use.`; } else { - response += `**Status:** ❌ Not responding or invalid\n`; - if (card.status_code) { + response += `**Status:** ❌ Unreachable\n`; + if ((card?.errors?.length ?? 0) > 0) { + response += `**Error:** ${card?.errors?.[0]}\n`; + } else if (card?.status_code) { response += `**HTTP Status:** ${card.status_code}\n`; } - if (card.errors.length > 0) { - response += `\n### Errors\n`; - card.errors.forEach((err) => { - response += `- ${err}\n`; - }); + } + + // Capabilities section + response += `\n### Capabilities\n`; + if (agent?.capabilities?.tools && agent.capabilities.tools.length > 0) { + if (!isHealthy) { + response += `> ⚠️ **Warning:** Agent is currently unreachable. Showing cached capabilities.\n\n`; } - response += `\n⚠️ This agent needs to be fixed before it can be used. Common issues:\n`; - response += `- Agent endpoint not reachable\n`; - response += `- Missing or invalid agent card at /.well-known/agent.json\n`; - response += `- HTTPS not configured\n`; + response += `**Tools Available:** ${agent.capabilities.tools_count}\n\n`; + agent.capabilities.tools.forEach((tool) => { + response += `- **${tool.name}**`; + if (tool.description) { + response += `: ${tool.description}`; + } + response += `\n`; + }); + + if (agent.capabilities.standard_operations && agent.capabilities.standard_operations.length > 0) { + response += `\n**Standard Operations:** ${agent.capabilities.standard_operations.join(', ')}\n`; + } + } else if (!isHealthy) { + response += `No cached capabilities available. Agent must be online to discover tools.\n`; + } else { + response += `Agent is online but capabilities could not be discovered. It may not be in the public registry.\n`; + } + + // Summary + response += `\n---\n`; + if (isHealthy && (agent?.capabilities?.tools?.length ?? 0) > 0) { + response += `✅ Agent is healthy and ready to use. Try \`test_adcp_agent\` to run a full workflow test.`; + } else if (isHealthy) { + response += `✅ Agent is responding but not in the registry. You can still use \`call_adcp_agent\` to execute tasks directly.`; + } else { + response += `❌ Agent is not responding. Check the URL and ensure the agent is running.`; } return response; @@ -2009,81 +2028,6 @@ export function createMemberToolHandlers( return response; }); - handlers.set('get_agent_capabilities', async (input) => { - const agentUrl = input.agent_url as string; - - // URL encode the agent URL for the path - const encodedUrl = encodeURIComponent(agentUrl); - const result = await callApi('GET', `/api/registry/agents?url=${encodedUrl}&capabilities=true`, memberContext); - - if (!result.ok) { - return `Failed to get agent capabilities: ${result.error}`; - } - - const data = result.data as { - agents: Array<{ - name: string; - url: string; - type: string; - protocol: string; - description?: string; - capabilities?: { - tools_count: number; - tools: Array<{ - name: string; - description?: string; - }>; - standard_operations?: string[]; - }; - }>; - }; - - if (!data.agents || data.agents.length === 0) { - // Try direct capabilities endpoint if not in registry - const directResult = await callApi('POST', '/api/adagents/validate-cards', memberContext, { - agent_urls: [agentUrl], - }); - - if (!directResult.ok) { - return `Agent not found in registry and couldn't fetch directly. The agent may not be publicly registered. Try check_agent_health first to verify the agent is online.`; - } - - return `Agent ${agentUrl} is not in the public registry. Use check_agent_health to verify it's online, then check its documentation for available capabilities.`; - } - - const agent = data.agents[0]; - let response = `## Agent Capabilities: ${agent.name || agentUrl}\n\n`; - response += `**URL:** ${agent.url}\n`; - response += `**Type:** ${agent.type}\n`; - response += `**Protocol:** ${agent.protocol}\n`; - if (agent.description) { - response += `**Description:** ${agent.description}\n`; - } - - if (agent.capabilities) { - response += `\n### Available Tools (${agent.capabilities.tools_count})\n`; - if (agent.capabilities.tools && agent.capabilities.tools.length > 0) { - agent.capabilities.tools.forEach((tool) => { - response += `\n**${tool.name}**\n`; - if (tool.description) { - response += `${tool.description}\n`; - } - }); - } - - if (agent.capabilities.standard_operations && agent.capabilities.standard_operations.length > 0) { - response += `\n### Standard AdCP Operations\n`; - agent.capabilities.standard_operations.forEach((op) => { - response += `- ${op}\n`; - }); - } - } else { - response += `\n_Capabilities not available. The agent may need to be contacted directly to discover its tools._\n`; - } - - return response; - }); - // ============================================ // E2E AGENT TESTING // ============================================ diff --git a/tests/addie/member-tools.test.ts b/tests/addie/member-tools.test.ts index 003f305746..8f52122aae 100644 --- a/tests/addie/member-tools.test.ts +++ b/tests/addie/member-tools.test.ts @@ -104,11 +104,13 @@ describe('MEMBER_TOOLS definitions', () => { expect(tool?.description).toContain('Slack account'); }); - it('has check_agent_health tool', () => { - const tool = MEMBER_TOOLS.find(t => t.name === 'check_agent_health'); + it('has probe_adcp_agent tool', () => { + const tool = MEMBER_TOOLS.find(t => t.name === 'probe_adcp_agent'); expect(tool).toBeDefined(); expect(tool?.input_schema.properties).toHaveProperty('agent_url'); expect(tool?.input_schema.required).toContain('agent_url'); + expect(tool?.description).toContain('online'); + expect(tool?.description).toContain('capabilities'); }); it('has check_publisher_authorization tool', () => { @@ -120,13 +122,6 @@ describe('MEMBER_TOOLS definitions', () => { expect(tool?.input_schema.required).toContain('agent_url'); }); - it('has get_agent_capabilities tool', () => { - const tool = MEMBER_TOOLS.find(t => t.name === 'get_agent_capabilities'); - expect(tool).toBeDefined(); - expect(tool?.input_schema.properties).toHaveProperty('agent_url'); - expect(tool?.input_schema.required).toContain('agent_url'); - }); - it('has draft_github_issue tool', () => { const tool = MEMBER_TOOLS.find(t => t.name === 'draft_github_issue'); expect(tool).toBeDefined(); From 09c415db5deb2189af9d4a5af09f69281c72f512 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 13 Jan 2026 13:58:03 -0500 Subject: [PATCH 74/77] feat: improve domain user membership UX (#754) - Filter domain users endpoint to exclude pending join requests and invitations - Add direct member addition for verified domain users (POST /api/organizations/:orgId/domain-users/add) - Auto-join users from verified domains when they create join requests - Add admin backfill endpoints for mass-adding domain users as members - Add email validation and input sanitization - Fix event listener accumulation in team.html - Update team.html UI with Add/Invite buttons and "Add All" functionality - Update admin domain health page with member backfill UI Co-authored-by: Claude Opus 4.5 --- .changeset/spicy-plants-rescue.md | 2 + server/public/admin-domain-health.html | 151 +++++++++++++ server/public/team.html | 172 +++++++++++++-- server/src/http.ts | 61 ++++++ server/src/routes/admin/domains.ts | 269 +++++++++++++++++++++++ server/src/routes/organizations.ts | 285 ++++++++++++++++++++++++- 6 files changed, 923 insertions(+), 17 deletions(-) create mode 100644 .changeset/spicy-plants-rescue.md 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/server/public/admin-domain-health.html b/server/public/admin-domain-health.html index c05dea2038..120ac6731b 100644 --- a/server/public/admin-domain-health.html +++ b/server/public/admin-domain-health.html @@ -290,6 +290,22 @@

Domain Health

+ + +
+
+
+
+ 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)
+
+ +
+ + +
@@ -1801,10 +1817,145 @@

All } } + // 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/team.html b/server/public/team.html index 02461b57ba..61caa34383 100644 --- a/server/public/team.html +++ b/server/public/team.html @@ -440,9 +440,12 @@

Verified Domains