Merge branch 'develop' into feat/app-store-reviews
This commit is contained in:
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ GEM
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
firecrawl-sdk (1.4.1)
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.11.1)
|
||||
@@ -1079,6 +1080,7 @@ DEPENDENCIES
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
firecrawl-sdk (~> 1.0)
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
|
||||
@@ -36,11 +36,18 @@ export function useConfig() {
|
||||
*/
|
||||
const enterprisePlanName = config.enterprisePlanName;
|
||||
|
||||
/**
|
||||
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
|
||||
* @type {boolean}
|
||||
*/
|
||||
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
|
||||
|
||||
return {
|
||||
hostURL,
|
||||
vapidPublicKey,
|
||||
enabledLanguages,
|
||||
isEnterprise,
|
||||
enterprisePlanName,
|
||||
inboxEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
"CONTACT_CREATED": "Contact created",
|
||||
"CONTACT_UPDATED": "Contact updated",
|
||||
"CONVERSATION_TYPING_ON": "Conversation Typing On",
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
|
||||
"INBOX_UPDATED": "Inbox updated"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
|
||||
+5
-1
@@ -5,6 +5,7 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
@@ -55,12 +56,15 @@ export default {
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const { inboxEventsEnabled } = useConfig();
|
||||
return {
|
||||
url: this.value.url || '',
|
||||
name: this.value.name || '',
|
||||
subscriptions: this.value.subscriptions || [],
|
||||
secretVisible: false,
|
||||
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
|
||||
supportedWebhookEvents: inboxEventsEnabled
|
||||
? [...SUPPORTED_WEBHOOK_EVENTS, 'inbox_updated']
|
||||
: SUPPORTED_WEBHOOK_EVENTS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
class AutoAssignment::AssignmentJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(inbox_id:)
|
||||
IN_FLIGHT_TTL = 5.minutes
|
||||
|
||||
# Coalesce per inbox: at most one AssignmentJob per inbox is in-flight
|
||||
# (queued or running) at any time. The marker carries a token so a job only
|
||||
# releases its own claim (a newer job may have taken it after a TTL lapse).
|
||||
def self.enqueue_for_inbox(inbox_id)
|
||||
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
|
||||
token = SecureRandom.uuid
|
||||
return false unless ::Redis::Alfred.set(key, token, nx: true, ex: IN_FLIGHT_TTL)
|
||||
|
||||
return true if perform_later(inbox_id: inbox_id, token: token)
|
||||
|
||||
# Enqueue was halted; release our own claim so the inbox isn't gated until the TTL.
|
||||
::Redis::Alfred.delete_if_equals(key, token)
|
||||
false
|
||||
rescue StandardError
|
||||
# Enqueue raised after we claimed the gate; release our own claim, then re-raise.
|
||||
::Redis::Alfred.delete_if_equals(key, token)
|
||||
raise
|
||||
end
|
||||
|
||||
def perform(inbox_id:, token: nil)
|
||||
inbox = Inbox.find_by(id: inbox_id)
|
||||
return unless inbox
|
||||
|
||||
service = AutoAssignment::AssignmentService.new(inbox: inbox)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
|
||||
Rails.logger.info "Assigned #{assigned_count} conversations for inbox #{inbox.id}"
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
|
||||
raise e if Rails.env.test?
|
||||
ensure
|
||||
release_in_flight(inbox_id, token)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Release the in-flight marker only if we still own it. The atomic
|
||||
# compare-and-delete ensures a job whose TTL lapsed can't delete a newer
|
||||
# job's claim. Tokenless (pre-deploy) jobs never claimed a key, so skip.
|
||||
def release_in_flight(inbox_id, token)
|
||||
return if token.nil?
|
||||
|
||||
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
|
||||
::Redis::Alfred.delete_if_equals(key, token)
|
||||
end
|
||||
|
||||
def bulk_assignment_limit
|
||||
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
|
||||
inboxes.each do |inbox|
|
||||
next unless inbox.auto_assignment_v2_enabled?
|
||||
|
||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,8 +15,10 @@ module AutoAssignmentHandler
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
if inbox.auto_assignment_v2_enabled?
|
||||
# Use new assignment system
|
||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
# Coalesces bursts of triggers per inbox. Fine if the job runs even when the
|
||||
# surrounding save rolls back: it only scans the inbox's current unassigned
|
||||
# conversations, so running it for an uncommitted change is harmless.
|
||||
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
|
||||
else
|
||||
# Use legacy assignment system
|
||||
# If conversation has a team, only consider team members for assignment
|
||||
|
||||
@@ -37,11 +37,14 @@ module Reauthorizable
|
||||
# Performed automatically if error threshold is breached
|
||||
# could used to manually prompt reauthorization if auth scope changes
|
||||
def prompt_reauthorization!
|
||||
state_changed = !reauthorization_required?
|
||||
|
||||
::Redis::Alfred.set(reauthorization_required_key, true)
|
||||
|
||||
reauthorization_handlers[self.class.name]&.call(self)
|
||||
|
||||
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
||||
dispatch_inbox_reauthorization_event(true) if state_changed
|
||||
end
|
||||
|
||||
def process_integration_hook_reauthorization_emails
|
||||
@@ -63,14 +66,24 @@ module Reauthorizable
|
||||
|
||||
# call this after you successfully Reauthorized the object in UI
|
||||
def reauthorized!
|
||||
state_changed = reauthorization_required?
|
||||
|
||||
::Redis::Alfred.delete(authorization_error_count_key)
|
||||
::Redis::Alfred.delete(reauthorization_required_key)
|
||||
|
||||
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
||||
dispatch_inbox_reauthorization_event(false) if state_changed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_inbox_reauthorization_event(reauthorization_required)
|
||||
return unless respond_to?(:inbox)
|
||||
return if inbox.blank?
|
||||
|
||||
inbox.dispatch_reauthorization_event(reauthorization_required)
|
||||
end
|
||||
|
||||
def reauthorization_handlers
|
||||
{
|
||||
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
|
||||
|
||||
@@ -211,6 +211,15 @@ class Inbox < ApplicationRecord
|
||||
account.feature_enabled?('assignment_v2')
|
||||
end
|
||||
|
||||
# Callers (Reauthorizable) only invoke this on a real transition, so the previous
|
||||
# value is always the inverse of the new boolean value.
|
||||
def dispatch_reauthorization_event(reauthorization_required)
|
||||
return if ENV['ENABLE_INBOX_EVENTS'].blank?
|
||||
|
||||
changed_attributes = { reauthorization_required: [!reauthorization_required, reauthorization_required] }
|
||||
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: changed_attributes)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_name_for_blank_name
|
||||
|
||||
@@ -23,7 +23,7 @@ class Inbox::EventDataPresenter < SimpleDelegator
|
||||
timezone: timezone,
|
||||
out_of_office_message: out_of_office_message,
|
||||
working_hours_enabled: working_hours_enabled,
|
||||
working_hours: working_hours,
|
||||
working_hours: working_hours.as_json,
|
||||
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
|
||||
@@ -72,15 +72,32 @@ class AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
def assign_conversation(conversation, agent)
|
||||
Current.executed_by = inbox.assignment_policy || inbox
|
||||
conversation.update!(assignee: agent)
|
||||
Current.executed_by = nil
|
||||
return false unless claim_and_assign(conversation, agent)
|
||||
|
||||
conversation.reload
|
||||
|
||||
rate_limiter = build_rate_limiter(agent)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
|
||||
dispatch_assignment_event(conversation, agent)
|
||||
true
|
||||
end
|
||||
|
||||
# Atomically claim the row so two bulk runs that overlap (the in-flight gate
|
||||
# is best-effort and can lapse on TTL) can't both assign the same conversation.
|
||||
def claim_and_assign(conversation, agent)
|
||||
Current.executed_by = inbox.assignment_policy || inbox
|
||||
|
||||
Conversation.transaction do
|
||||
locked = inbox.conversations
|
||||
.where(id: conversation.id, assignee_id: nil)
|
||||
.lock('FOR UPDATE SKIP LOCKED')
|
||||
.first
|
||||
next false unless locked
|
||||
|
||||
locked.update!(assignee: agent)
|
||||
true
|
||||
end
|
||||
ensure
|
||||
Current.executed_by = nil
|
||||
end
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<% end %>
|
||||
enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>,
|
||||
helpUrls: <%= feature_help_urls.to_json.html_safe %>,
|
||||
inboxEventsEnabled: '<%= ENV['ENABLE_INBOX_EVENTS'].present? %>',
|
||||
selectedLocale: '<%= I18n.locale %>'
|
||||
}
|
||||
window.globalConfig = <%= raw @global_config.to_json %>
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
# App Store Reviews Inbox Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add Apple App Store reviews as a Chatwoot inbox, similar to the Google Play Reviews inbox, so agents can read App Store reviews in Chatwoot and post developer responses from the conversation reply box.
|
||||
|
||||
## API Findings
|
||||
|
||||
- Use the official App Store Connect API, not public RSS/iTunes review feeds.
|
||||
- App Store Connect API uses API keys and ES256 JWT bearer tokens, not OAuth user sign-in.
|
||||
- Required credentials:
|
||||
- Issuer ID
|
||||
- Key ID
|
||||
- `.p8` private key
|
||||
- JWTs should be short-lived. Apple generally rejects App Store Connect API tokens with expiration more than 20 minutes in the future.
|
||||
- Reviews can be fetched from:
|
||||
- `GET /v1/apps/{id}/customerReviews`
|
||||
- `GET /v1/appStoreVersions/{id}/customerReviews`
|
||||
- Review fields include:
|
||||
- `rating`
|
||||
- `title`
|
||||
- `body`
|
||||
- `reviewerNickname`
|
||||
- `createdDate`
|
||||
- `territory`
|
||||
- `response`
|
||||
- Developer responses can be included with `include=response`.
|
||||
- Developer replies are created or updated through:
|
||||
- `POST /v1/customerReviewResponses`
|
||||
- A review can have at most one developer response. Posting another response updates/replaces the existing one.
|
||||
- Apple says responses can take up to 24 hours to appear publicly.
|
||||
- Required App Store Connect role for responding: Account Holder, Admin, or Customer Support.
|
||||
- No Apple equivalent of Google Play's 7-day review fetch limit was found. Do not add a 7-day reply window unless real API testing proves one exists.
|
||||
|
||||
References:
|
||||
|
||||
- https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests
|
||||
- https://developer.apple.com/documentation/appstoreconnectapi/customer-review-responses
|
||||
- https://developer.apple.com/documentation/appstoreconnectapi/post-v1-customerreviewresponses
|
||||
- https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app_store_version
|
||||
- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/respond-to-reviews/
|
||||
|
||||
## Product Shape
|
||||
|
||||
The App Store inbox should be a new channel type, separate from Google Play:
|
||||
|
||||
- Channel name: App Store Reviews
|
||||
- One inbox per App Store Connect app per Chatwoot account.
|
||||
- Setup should be credential-form based, not OAuth redirect based.
|
||||
- Agents should see each review as a conversation.
|
||||
- Agents should be able to reply once; later replies update the App Store response.
|
||||
- Existing developer responses from App Store Connect should be mirrored as outgoing messages.
|
||||
- Review title, body, rating, territory, and reviewer nickname should be visible in the conversation.
|
||||
|
||||
## Data Model
|
||||
|
||||
Add `channel_app_store`.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `account_id`
|
||||
- `app_id` - App Store Connect app resource ID
|
||||
- `bundle_id`
|
||||
- `app_name`
|
||||
- `provider_config` - non-secret metadata only, if needed
|
||||
- `issuer_id`
|
||||
- `key_id`
|
||||
- `private_key`
|
||||
- `last_synced_at`
|
||||
- timestamps
|
||||
|
||||
Indexes:
|
||||
|
||||
- unique index on `[:account_id, :app_id]`
|
||||
|
||||
Security:
|
||||
|
||||
- Treat `.p8` private key as a sensitive credential.
|
||||
- Prefer explicit encrypted columns for `issuer_id`, `key_id`, and `private_key`.
|
||||
- Avoid storing the private key inside unencrypted JSONB.
|
||||
- Follow existing `Chatwoot.encryption_configured?` patterns used by channel credentials.
|
||||
|
||||
Model wiring:
|
||||
|
||||
- Add `Channel::AppStore`.
|
||||
- Add `Account#app_store_channels`.
|
||||
- Add `Inbox#app_store?`.
|
||||
- Add API serialization for `app_id`, `bundle_id`, `app_name`, and `last_synced_at`.
|
||||
- Add `SendReplyJob` mapping to `AppStore::SendOnAppStoreService`.
|
||||
|
||||
## Backend Services
|
||||
|
||||
### `AppStoreConnect::TokenService`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Build an ES256 JWT using the channel credentials.
|
||||
- Use existing `jwt` gem.
|
||||
- Parse private key with `OpenSSL::PKey`.
|
||||
- Set JWT header:
|
||||
- `alg: ES256`
|
||||
- `kid: key_id`
|
||||
- `typ: JWT`
|
||||
- Set JWT payload:
|
||||
- `iss: issuer_id`
|
||||
- `iat`
|
||||
- `exp`
|
||||
- `aud: appstoreconnect-v1`
|
||||
- Cache token per channel until close to expiry.
|
||||
|
||||
### `AppStoreConnect::Client`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Add bearer token auth header.
|
||||
- Fetch reviews with pagination.
|
||||
- Fetch included developer responses.
|
||||
- Post developer responses.
|
||||
- Raise clear errors for:
|
||||
- 401 invalid credentials
|
||||
- 403 missing permissions
|
||||
- 404 app/review not found
|
||||
- 409/422 invalid response payload
|
||||
- 429 rate limited
|
||||
- 5xx Apple errors
|
||||
|
||||
Suggested methods:
|
||||
|
||||
- `list_reviews(app_id, cursor: nil)`
|
||||
- `reply_to_review(review_id, response_body)`
|
||||
- `fetch_app(app_id)` or `validate_app_access(app_id)`
|
||||
|
||||
## Import Pipeline
|
||||
|
||||
Add jobs:
|
||||
|
||||
- `Inboxes::FetchAppStoreReviewInboxesJob`
|
||||
- `Inboxes::FetchAppStoreReviewsJob`
|
||||
|
||||
Polling behavior:
|
||||
|
||||
- Scheduled polling similar to Google Play.
|
||||
- Skip suspended accounts.
|
||||
- Use `last_synced_at` to avoid excessive polling.
|
||||
- Page through review results using `links.next`.
|
||||
- Consider sorting by `-createdDate`.
|
||||
|
||||
Add `AppStore::ReviewBuilder`.
|
||||
|
||||
Mapping:
|
||||
|
||||
- Apple review ID maps to `ContactInbox#source_id`.
|
||||
- One review maps to one conversation.
|
||||
- Review edits should be handled idempotently.
|
||||
- Incoming message source ID can be based on review ID plus `createdDate` or a stable edit/version field if Apple exposes one.
|
||||
- Existing developer response maps to an outgoing message.
|
||||
- Developer response message source ID should use Apple response ID if present.
|
||||
|
||||
Message content:
|
||||
|
||||
- Include star rating.
|
||||
- Include title.
|
||||
- Include body.
|
||||
- Include a compact footer with territory and reviewer nickname when useful.
|
||||
|
||||
Message timestamps:
|
||||
|
||||
- Use Apple `createdDate` as `created_at` and `updated_at` for imported review messages.
|
||||
- Do not use import time for review messages.
|
||||
|
||||
Metadata:
|
||||
|
||||
Store under `content_attributes[:app_store]`:
|
||||
|
||||
- `rating`
|
||||
- `title`
|
||||
- `territory`
|
||||
- `reviewer_nickname`
|
||||
- `created_date`
|
||||
- `response_state`
|
||||
- `response_id`
|
||||
|
||||
## Reply Pipeline
|
||||
|
||||
Add `AppStore::SendOnAppStoreService`.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Use `conversation.contact_inbox.source_id` as the Apple review ID.
|
||||
- Call `POST /v1/customerReviewResponses`.
|
||||
- On success:
|
||||
- Update `message.source_id` with Apple response ID if returned.
|
||||
- Mark message as sent/delivered through `Messages::StatusUpdateService`.
|
||||
- On failure:
|
||||
- Mark message as failed through `Messages::StatusUpdateService`.
|
||||
- Store `external_error`.
|
||||
|
||||
Constraints:
|
||||
|
||||
- Disable attachments.
|
||||
- Disable rich-text formatting.
|
||||
- Confirm Apple's response length limit during implementation. Do not guess a hard cap unless verified.
|
||||
|
||||
## Frontend
|
||||
|
||||
Add App Store Reviews to inbox creation.
|
||||
|
||||
Setup form fields:
|
||||
|
||||
- Inbox name
|
||||
- App Store Connect app ID
|
||||
- Bundle ID, optional if app ID is enough
|
||||
- Issuer ID
|
||||
- Key ID
|
||||
- Private key `.p8`
|
||||
|
||||
Backend should validate credentials before creating the inbox by calling App Store Connect.
|
||||
|
||||
Frontend wiring:
|
||||
|
||||
- Add `INBOX_TYPES.APP_STORE`.
|
||||
- Add `isAnAppStoreChannel` / equivalent composable and mixin helpers.
|
||||
- Add channel icon.
|
||||
- Add i18n strings in `en.json` only.
|
||||
- Add channel to inbox list and channel factory.
|
||||
- Add API client for creating/validating App Store channel.
|
||||
- Add plain text editor config for `Channel::AppStore`.
|
||||
- Add reply max length only after Apple limit is confirmed.
|
||||
|
||||
Unsupported settings:
|
||||
|
||||
- Hide bots.
|
||||
- Hide business hours.
|
||||
- Hide CSAT.
|
||||
- Hide help center.
|
||||
- Hide channel preferences that do not apply.
|
||||
- Disable attachments.
|
||||
|
||||
## Tests
|
||||
|
||||
Backend specs:
|
||||
|
||||
- `Channel::AppStore` validations and associations.
|
||||
- JWT generation with generated EC key.
|
||||
- Client review pagination.
|
||||
- Client reply request body.
|
||||
- Client error handling.
|
||||
- Inbox creation credential validation.
|
||||
- Review builder creates contact, conversation, incoming message.
|
||||
- Review builder idempotency.
|
||||
- Review builder mirrors existing developer response.
|
||||
- Send service success and failure.
|
||||
- Polling job skips suspended accounts.
|
||||
- Polling job respects sync interval.
|
||||
|
||||
Frontend specs:
|
||||
|
||||
- Channel detection helper/composable.
|
||||
- Inbox type icon/readable label.
|
||||
- Setup form validation.
|
||||
- Reply box behavior for unsupported attachments/formatting.
|
||||
|
||||
## Resolved Open Questions
|
||||
|
||||
### App-level reviews vs version-level reviews
|
||||
|
||||
Use app-level reviews as the primary fetch path:
|
||||
|
||||
- `GET /v1/apps/{id}/customerReviews`
|
||||
|
||||
Reasoning:
|
||||
|
||||
- Chatwoot inboxes should map to an app, not to a specific App Store version.
|
||||
- Apple documents app-level customer reviews as the endpoint for getting reviews for a specific app.
|
||||
- Version-level reviews are still useful for narrower workflows, but they would make inbox setup more complicated and could fragment one app's support queue across several inboxes.
|
||||
|
||||
Implementation decision:
|
||||
|
||||
- Store the App Store Connect app resource ID on `Channel::AppStore`.
|
||||
- Fetch app-level reviews by default.
|
||||
- Keep version-level support out of MVP.
|
||||
- Add `platform` or `app_store_version_id` later only if real API testing shows app-level reviews mix platforms in a way agents cannot work with.
|
||||
|
||||
Still needs real API validation:
|
||||
|
||||
- Confirm whether app-level reviews include all platforms and all versions for a multi-platform app.
|
||||
- Confirm whether app-level review payload includes enough context to identify platform/version. The documented review attributes include `rating`, `title`, `body`, `reviewerNickname`, `createdDate`, and `territory`, but not platform/version.
|
||||
|
||||
### Response payload shape
|
||||
|
||||
Use JSON:API format for `POST /v1/customerReviewResponses`.
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "customerReviewResponses",
|
||||
"attributes": {
|
||||
"responseBody": "Thanks for the feedback."
|
||||
},
|
||||
"relationships": {
|
||||
"review": {
|
||||
"data": {
|
||||
"type": "customerReviews",
|
||||
"id": "CUSTOMER_REVIEW_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expected successful response:
|
||||
|
||||
- HTTP `201 Created`.
|
||||
- Response resource type: `customerReviewResponses`.
|
||||
- Response fields can include:
|
||||
- `responseBody`
|
||||
- `lastModifiedDate`
|
||||
- `state`
|
||||
- `review`
|
||||
|
||||
Implementation decision:
|
||||
|
||||
- Use the returned customer review response ID as outgoing message `source_id`.
|
||||
- Store `state` and `lastModifiedDate` in `content_attributes[:app_store]` when present.
|
||||
|
||||
### Review edits and idempotency
|
||||
|
||||
Apple's documented `CustomerReview.Attributes` include `createdDate`, but not an update timestamp for the customer review itself.
|
||||
|
||||
Implementation decision:
|
||||
|
||||
- Use the Apple review ID as the stable incoming message source ID.
|
||||
- Create one incoming message per review.
|
||||
- If a fetched review with the same ID has changed title/body/rating, update the existing message content and metadata instead of creating a new message.
|
||||
- Do not append edit history in MVP because the API docs do not expose a review edit timestamp.
|
||||
|
||||
Still needs real API validation:
|
||||
|
||||
- Confirm how Apple represents a reviewer editing an existing review in API responses.
|
||||
- Confirm whether edited reviews preserve the same review ID.
|
||||
|
||||
### Response body length
|
||||
|
||||
No official customer review response length limit was found in the App Store Connect API docs checked.
|
||||
|
||||
Implementation decision:
|
||||
|
||||
- Do not hardcode a special App Store response length limit in MVP.
|
||||
- Use Chatwoot's general reply validation on the frontend.
|
||||
- Let Apple return `409` or `422` for invalid response payloads and surface the error through `external_error`.
|
||||
|
||||
Still needs real API validation:
|
||||
|
||||
- Check whether App Store Connect applies a hidden maximum length for `responseBody`.
|
||||
|
||||
### Rate limits and retries
|
||||
|
||||
Apple documents rate limits through the `X-Rate-Limit` response header.
|
||||
|
||||
Header shape:
|
||||
|
||||
```text
|
||||
user-hour-lim:3500;user-hour-rem:500;
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Limits apply to requests using the same API key.
|
||||
- The window is a rolling hour.
|
||||
- Exceeding the limit returns HTTP `429` with `RATE_LIMIT_EXCEEDED`.
|
||||
|
||||
Implementation decision:
|
||||
|
||||
- Parse and log `X-Rate-Limit` headers in the client.
|
||||
- On `429`, do not mark the inbox broken.
|
||||
- Re-enqueue the fetch job later with backoff.
|
||||
- Keep polling conservative, similar to Google Play, and page with `limit=200`.
|
||||
|
||||
### Inbox scope for multiple platforms and versions
|
||||
|
||||
Implementation decision:
|
||||
|
||||
- MVP scope is one inbox per App Store Connect app resource ID per Chatwoot account.
|
||||
- Do not create separate inboxes by platform or app version.
|
||||
- Store `platform` only if we can reliably derive it during setup or fetch.
|
||||
|
||||
Reasoning:
|
||||
|
||||
- This matches the way agents think about supporting one app.
|
||||
- It avoids forcing customers to know App Store version resource IDs.
|
||||
- It keeps parity with Google Play's one-app-per-inbox model.
|
||||
|
||||
Still needs real API validation:
|
||||
|
||||
- Confirm if app-level reviews for multi-platform apps are agent-friendly without platform separation.
|
||||
|
||||
## Remaining Risks
|
||||
|
||||
- Credential storage must be handled carefully because `.p8` private keys are highly sensitive.
|
||||
- Apple responses can remain pending for up to 24 hours, so Chatwoot send status and public App Store visibility are not the same.
|
||||
- A real App Store Connect app and API key are required before finalizing response-length handling, review-edit behavior, and multi-platform behavior.
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. Add model, migration, associations, and inbox serialization.
|
||||
2. Add JWT token service and low-level App Store Connect client.
|
||||
3. Add backend channel creation/validation endpoint.
|
||||
4. Add review fetch job and review builder.
|
||||
5. Add send service and `SendReplyJob` mapping.
|
||||
6. Add frontend inbox setup and channel helpers.
|
||||
7. Add settings/reply-box restrictions.
|
||||
8. Add specs.
|
||||
9. Manually verify with a real App Store Connect app and API key.
|
||||
10. Revisit shared abstractions with Google Play after both integrations work.
|
||||
|
||||
## Potential Shared Abstraction Later
|
||||
|
||||
After Google Play and App Store are both implemented, consider extracting shared store-review behavior:
|
||||
|
||||
- Store review polling orchestration.
|
||||
- Review-to-conversation builder conventions.
|
||||
- Reply status handling.
|
||||
- Unsupported inbox settings.
|
||||
- Plain-text reply channel behavior.
|
||||
|
||||
Do not extract this upfront. Let both implementations settle first.
|
||||
@@ -0,0 +1,103 @@
|
||||
class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
_account_id, _portal_id, user_id, generation_id = job.arguments
|
||||
reason = "firecrawl exhausted: #{error.message}"
|
||||
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
|
||||
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id)
|
||||
return if Onboarding::HelpCenterGenerationState.current(generation_id).present?
|
||||
|
||||
process(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: User.find(user_id),
|
||||
generation_id: generation_id
|
||||
)
|
||||
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
|
||||
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
|
||||
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process(account:, portal:, user:, generation_id:)
|
||||
plan = Onboarding::HelpCenterCurator.new(account: account).perform
|
||||
articles = create_categories_and_build_article_payloads(portal, plan)
|
||||
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: articles.size)
|
||||
enqueue_writer_jobs(
|
||||
account_id: account.id,
|
||||
portal_id: portal.id,
|
||||
user_id: user.id,
|
||||
generation_id: generation_id,
|
||||
articles: articles
|
||||
)
|
||||
end
|
||||
|
||||
def create_categories_and_build_article_payloads(portal, plan)
|
||||
ActiveRecord::Base.transaction do
|
||||
categories_by_name = create_categories(portal, plan['categories'])
|
||||
articles = build_article_payloads(
|
||||
plan['articles'],
|
||||
categories_by_name,
|
||||
plan['allowed_urls']
|
||||
)
|
||||
|
||||
if articles.empty?
|
||||
raise Onboarding::HelpCenterErrors::CurationSkipped,
|
||||
'no articles after category or URL filtering'
|
||||
end
|
||||
|
||||
articles
|
||||
end
|
||||
end
|
||||
|
||||
def create_categories(portal, categories)
|
||||
locale = portal.default_locale
|
||||
Array(categories).each_with_index.with_object({}) do |(cat, idx), acc|
|
||||
name = cat['name'].to_s.strip
|
||||
next if name.blank?
|
||||
|
||||
record = portal.categories.create!(
|
||||
name: name,
|
||||
description: cat['description'].to_s.strip.presence,
|
||||
slug: "#{name.parameterize}-#{SecureRandom.hex(3)}",
|
||||
locale: locale,
|
||||
position: (idx + 1) * 10
|
||||
)
|
||||
acc[name] = record
|
||||
end
|
||||
end
|
||||
|
||||
def build_article_payloads(articles, categories_by_name, allowed_urls)
|
||||
allowed_urls = Array(allowed_urls).to_set
|
||||
Array(articles).filter_map do |article|
|
||||
category_id = categories_by_name[article['category_name'].to_s]&.id
|
||||
next if category_id.nil?
|
||||
|
||||
urls = Array(article['urls']).select { |url| allowed_urls.include?(url) }
|
||||
next if urls.empty?
|
||||
|
||||
article.merge('category_id' => category_id, 'urls' => urls)
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
|
||||
articles.each do |article|
|
||||
Onboarding::HelpCenterArticleWriterJob.perform_later(
|
||||
account_id, portal_id, user_id, generation_id, { article: article }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_and_broadcast(user:, generation_id:, reason:)
|
||||
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
|
||||
Onboarding::HelpCenterBroadcaster.completed(
|
||||
user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
discard_on Onboarding::HelpCenterErrors::ArticleBuildFailed do |job, error|
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id, article_payload)
|
||||
user = User.find(user_id)
|
||||
payload = article_payload.with_indifferent_access
|
||||
article = Onboarding::HelpCenterArticleBuilder.new(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: user,
|
||||
article: payload[:article]
|
||||
).perform
|
||||
|
||||
finalize(user: user, generation_id: generation_id, article: article)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_writer_failure(error)
|
||||
user, generation_id = failure_context
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
|
||||
finalize(user: user, generation_id: generation_id, article: nil)
|
||||
end
|
||||
|
||||
def failure_context
|
||||
_account_id, _portal_id, user_id, generation_id = arguments
|
||||
[User.find_by(id: user_id), generation_id]
|
||||
end
|
||||
|
||||
def finalize(user:, generation_id:, article:)
|
||||
result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
|
||||
if article
|
||||
Onboarding::HelpCenterBroadcaster.article_generated(
|
||||
user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
|
||||
)
|
||||
end
|
||||
return unless result[:completed]
|
||||
|
||||
Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
|
||||
rescue Onboarding::HelpCenterGenerationState::Missing => e
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class Captain::Llm::ArticleWriterSchema < RubyLLM::Schema
|
||||
CONTENT_DESCRIPTION = 'Full article body in clean Markdown. Use headings, lists, and code fences where appropriate. ' \
|
||||
'Preserve steps, code samples, FAQs, troubleshooting detail. Strip marketing copy, navigation breadcrumbs, ' \
|
||||
'social/share footers, "edit this page" links, repeated CTAs. ' \
|
||||
'Total length must stay under 18000 characters; trim repetition and tangents before cutting substance.'.freeze
|
||||
TITLE_DESCRIPTION = 'Concise article title (max 80 chars). Plain text, no markdown.'.freeze
|
||||
DESCRIPTION_DESCRIPTION = 'One-sentence summary (max 200 chars) describing what the article teaches.'.freeze
|
||||
|
||||
string :title, description: TITLE_DESCRIPTION, max_length: 80
|
||||
string :description, description: DESCRIPTION_DESCRIPTION, max_length: 200
|
||||
string :content, description: CONTENT_DESCRIPTION, max_length: 18_000
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
class Captain::Llm::ArticleWriterService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::Llm::ArticleWriterSchema
|
||||
SOURCE_MAX_LENGTH = 60_000
|
||||
|
||||
# source_pages: Array<{ url: String, markdown: String }>, 1-3 entries.
|
||||
pattr_initialize [:account!, :source_pages!, { hint_title: nil }]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: writer_model, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: extract_payload(response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_payload(message)
|
||||
return {} if message.blank?
|
||||
|
||||
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
|
||||
{
|
||||
title: data[:title].to_s.strip,
|
||||
description: data[:description].to_s.strip,
|
||||
content: data[:content].to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are rewriting web page content into a clean help-center article for a customer-support knowledge base.
|
||||
You may receive 1 to 3 source pages. When given multiple sources, merge them into ONE coherent article:
|
||||
deduplicate identical instructions, do not repeat the same step in different words, and order content
|
||||
by the natural reading flow of the merged topic. When sources contradict, prefer the more authoritative
|
||||
or detailed version. The result must read like a single article, not a stitched-together collage.
|
||||
|
||||
Preserve the substance: keep instructions, steps, code samples, configuration, troubleshooting, and FAQs intact.
|
||||
Strip marketing copy, navigation breadcrumbs, "share this page" footers, repeated CTAs, and links to unrelated pages.
|
||||
Output well-formatted Markdown — use headings, lists, and code fences where appropriate.
|
||||
The body must stay under 18000 characters. If the combined sources are longer, trim repetition and tangents
|
||||
before cutting steps or critical detail. Never invent content the sources do not support.
|
||||
|
||||
Write the title, description, and body in #{locale_name}.
|
||||
If a source page is in another language, translate as you rewrite — do not copy source-language text into the output.
|
||||
Code samples, command-line examples, API field names, and proper nouns stay in their original form.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
pages = Array(source_pages).reject { |p| p[:markdown].to_s.blank? }
|
||||
per_source_cap = pages.size.positive? ? SOURCE_MAX_LENGTH / pages.size : SOURCE_MAX_LENGTH
|
||||
|
||||
sections = pages.each_with_index.map do |page, idx|
|
||||
body = page[:markdown].to_s.truncate(per_source_cap, omission: "\n\n[source truncated for length]")
|
||||
"=== Source #{idx + 1} of #{pages.size} (#{page[:url]}) ===\n#{body}"
|
||||
end
|
||||
|
||||
parts = [
|
||||
("Suggested title (you may rewrite): #{hint_title}" if hint_title.present?),
|
||||
'Source pages (Markdown):',
|
||||
sections.join("\n\n")
|
||||
].compact
|
||||
parts.join("\n\n")
|
||||
end
|
||||
|
||||
def locale_name
|
||||
code = account.locale.to_s
|
||||
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
|
||||
end
|
||||
|
||||
def event_name
|
||||
'article_writer'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
# Rewrite runs on the operator's OpenAI key during onboarding; should not
|
||||
# debit the customer's captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def writer_model
|
||||
'gpt-5.2'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
class Captain::Llm::HelpCenterCurationSchema < RubyLLM::Schema
|
||||
CATEGORIES_DESCRIPTION = 'High-level categories that group the chosen articles. Use only as many ' \
|
||||
'as the content naturally breaks into. Names must be short (1-3 words) and reusable.'.freeze
|
||||
ARTICLES_DESCRIPTION = 'A curated starting set of help-center articles selected from the input URL list. ' \
|
||||
'Quality over quantity: only include pages with clear, high-value, substantive help ' \
|
||||
'content. Skip blog posts, marketing/landing pages, login, pricing, legal, careers, ' \
|
||||
'customer testimonials, press, about/company, whitepapers, support contact pages, ' \
|
||||
'terms of service, privacy policy.'.freeze
|
||||
TITLE_DESCRIPTION = 'Concise article title (max 80 chars), rewritten if the source title is too long or marketing-y.'.freeze
|
||||
CATEGORY_DESCRIPTION = 'One sentence describing what kind of articles belong in this category.'.freeze
|
||||
URLS_DESCRIPTION = '1 to 3 source URLs from the input list. Prefer grouping when pages cover related ' \
|
||||
'aspects of the same topic — overview + deep-dive, FAQ + how-to, policy + FAQ, ' \
|
||||
'parent topic + its troubleshooting page. Merged sources give the writer more ' \
|
||||
'context and produce stronger articles than several thin stubs.'.freeze
|
||||
|
||||
array :categories, description: CATEGORIES_DESCRIPTION, min_items: 1, max_items: 10 do
|
||||
object do
|
||||
string :name, description: 'Short, human-readable category name (1-3 words).', max_length: 60
|
||||
string :description, description: CATEGORY_DESCRIPTION, max_length: 200
|
||||
end
|
||||
end
|
||||
|
||||
array :articles, description: ARTICLES_DESCRIPTION, min_items: 1, max_items: 25 do
|
||||
object do
|
||||
array :urls, description: URLS_DESCRIPTION, min_items: 1, max_items: 3, of: :string
|
||||
string :title, description: TITLE_DESCRIPTION, max_length: 80
|
||||
string :category_name, description: 'Must exactly match one of the names emitted in the categories field.', max_length: 60
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,157 @@
|
||||
class Captain::Llm::HelpCenterCurationService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::Llm::HelpCenterCurationSchema
|
||||
MAX_LINKS_IN_PROMPT = 50
|
||||
IGNORED_URL_PATTERN = /\.(?:pdf|jpe?g|png|gif|webp|svg|ico|bmp|tiff?|avif|heic)(?:\?|#|$)/i
|
||||
# This model consistently outperforms 5.2 in generating tighter and more
|
||||
# accurate curations.
|
||||
CURATION_MODEL = 'gpt-4.1'.freeze
|
||||
|
||||
pattr_initialize [:account!, :links!]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: CURATION_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: extract_payload(response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_payload(message)
|
||||
return { categories: [], articles: [] } if message.blank?
|
||||
|
||||
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
|
||||
articles = Array(data[:articles])
|
||||
used_names = articles.map { |a| a[:category_name].to_s }
|
||||
categories = Array(data[:categories]).select { |c| used_names.include?(c[:name].to_s) }
|
||||
{ categories: categories, articles: articles }
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are curating a help center for a company's customer-support widget.
|
||||
You will be given a list of pages discovered on the company's website.
|
||||
Pick pages that would make genuinely useful help-center articles for end users —
|
||||
substantive how-to, FAQ, troubleshooting, policy, getting-started, account/billing
|
||||
help, or product guide content.
|
||||
|
||||
This is a STARTING SET for the user, not a comprehensive corpus. The user will add
|
||||
more articles later. Each article you pick costs downstream time, compute, and
|
||||
money to scrape and rewrite — be deliberate. Only include pages with clear,
|
||||
high-value, substantive help content. When unsure about a page's value, leave it
|
||||
out. 8 strong articles beat 20 padded ones, even when the input has 20+ candidates.
|
||||
|
||||
Quality over quantity: do not pad with thin, overview, or marketing-adjacent pages
|
||||
to hit a target count. If a site has only a few genuinely useful pages, return only
|
||||
those few. The schema allows up to 25 articles, but treat that as a hard ceiling,
|
||||
not a target — most sites should land well under it.
|
||||
|
||||
Skip marketing/landing pages, blog posts, login, pricing tiers, legal, careers, press, investor pages.
|
||||
Group your picks into reusable categories — use as many as the content naturally breaks into.
|
||||
Use the URL paths and page titles to judge relevance — do not invent URLs.
|
||||
|
||||
URL-path priority (preference order, not hard rules):
|
||||
- First tier — almost always pick when present. Paths containing /support, /help,
|
||||
/docs, /documentation, /faq, /faqs, /kb, /knowledge-base, /learn, /guides,
|
||||
/getting-started, /how-to, /tutorial, /troubleshoot.
|
||||
- Second tier — pick when the page carries user-relevant information a customer
|
||||
would ask support about. Paths like /features, /pricing, /plans, /shipping,
|
||||
/returns, /warranty, /security, individual product or category pages. Prefer
|
||||
these only after first-tier picks; if a topic exists in both tiers, prefer the
|
||||
first-tier URL.
|
||||
- Skip — promotional, navigational, or boilerplate paths: /blog, /news, /press,
|
||||
/careers, /jobs, /about, /team, /investors, /customers, /testimonials,
|
||||
/case-studies, /login, /signup, /register, /legal, /terms, /privacy.
|
||||
|
||||
For each article, group 1 to 3 URLs that together cover a single topic. PREFER
|
||||
grouping whenever pages overlap or complement each other — merged sources give
|
||||
the writer more context and produce a stronger article than two thin stubs.
|
||||
|
||||
Strong signals to group multiple URLs (treat any of these as a green light):
|
||||
- Same topic from different angles: overview + deep-dive, FAQ + how-to,
|
||||
policy + FAQ, feature page + feature docs.
|
||||
- Parent topic + its troubleshooting page (e.g. "Bank reconciliation" +
|
||||
"Problems with bank reconciliation"; "SSO setup" + "SSO not working").
|
||||
- Variant-specific guides on the same topic ("SSO setup" + "SSO with Okta";
|
||||
"Webhooks overview" + "Webhook payload reference").
|
||||
- A how-to split across step or platform pages (install on iOS + Android + web).
|
||||
- FAQ entries that match a deep-dive article elsewhere on the site.
|
||||
|
||||
Before finalizing your picks, scan them for merge candidates: if two URLs are
|
||||
about the same topic, they should almost always be one article, not two.
|
||||
|
||||
Don't group across distinct topics that merely share a category ("Setting up SSO"
|
||||
and "Setting up MFA" stay separate). If a URL is marketing for a feature and
|
||||
another is the feature's docs, pick the docs and skip the marketing.
|
||||
|
||||
Write all category names, category descriptions, and article titles in #{locale_name}.
|
||||
The input page titles and descriptions may be in another language; translate the labels you emit into #{locale_name}.
|
||||
Keep URLs unchanged.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
parts = [
|
||||
"Company: #{account.name}",
|
||||
("Description: #{brand_info[:description]}" if brand_info[:description].present?),
|
||||
("Industries: #{industries_text}" if industries_text.present?),
|
||||
'Discovered pages (url — title — description):',
|
||||
formatted_links
|
||||
].compact
|
||||
parts.join("\n")
|
||||
end
|
||||
|
||||
def locale_name
|
||||
code = account.locale.to_s
|
||||
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
|
||||
end
|
||||
|
||||
def formatted_links
|
||||
Array(links).reject { |link| ignored_url?(link) }.first(MAX_LINKS_IN_PROMPT).map do |link|
|
||||
data = link.is_a?(Hash) ? link.deep_symbolize_keys : {}
|
||||
"- #{data[:url]} — #{data[:title].to_s.strip} — #{data[:description].to_s.strip}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def ignored_url?(link)
|
||||
url = link.is_a?(Hash) ? link.deep_symbolize_keys[:url].to_s : link.to_s
|
||||
url.match?(IGNORED_URL_PATTERN)
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def industries_text
|
||||
Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence
|
||||
end
|
||||
|
||||
def event_name
|
||||
'help_center_curation'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
# Onboarding curation runs on the operator's OpenAI key; it should not
|
||||
# debit the customer's captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Firecrawl::Configuration
|
||||
INSTALLATION_CONFIG_KEY = 'CAPTAIN_FIRECRAWL_API_KEY'.freeze
|
||||
EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
DEFAULT_SCRAPE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
module_function
|
||||
|
||||
def configured?
|
||||
api_key.present?
|
||||
end
|
||||
|
||||
def client
|
||||
key = api_key
|
||||
raise ::Firecrawl::FirecrawlError, "#{INSTALLATION_CONFIG_KEY} is not configured" if key.blank?
|
||||
|
||||
::Firecrawl::Client.new(api_key: key)
|
||||
end
|
||||
|
||||
def api_key
|
||||
InstallationConfig.find_by(name: INSTALLATION_CONFIG_KEY)&.value
|
||||
end
|
||||
|
||||
def default_scrape_options(max_age: DEFAULT_SCRAPE_MAX_AGE_MS)
|
||||
::Firecrawl::Models::ScrapeOptions.new(
|
||||
formats: ['markdown'],
|
||||
only_main_content: true,
|
||||
exclude_tags: EXCLUDE_TAGS,
|
||||
max_age: max_age
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
class Onboarding::HelpCenterArticleBuilder
|
||||
BuildFailed = Onboarding::HelpCenterErrors::ArticleBuildFailed
|
||||
|
||||
def initialize(account:, portal:, user:, article:)
|
||||
@account = account
|
||||
@portal = portal
|
||||
@user = user
|
||||
|
||||
spec = article.with_indifferent_access
|
||||
@urls = Array(spec[:urls]).map(&:to_s).reject(&:blank?)
|
||||
@title = spec[:title]
|
||||
@category_id = spec[:category_id]
|
||||
end
|
||||
|
||||
def perform
|
||||
raise BuildFailed, 'no source urls supplied' if @urls.empty?
|
||||
|
||||
source_pages = scrape(@urls)
|
||||
raise BuildFailed, "scrape produced no usable pages for #{@urls.join(', ')}" if source_pages.empty?
|
||||
|
||||
payload = rewrite(source_pages)
|
||||
|
||||
@portal.articles.create!(
|
||||
title: payload[:title],
|
||||
description: payload[:description].presence,
|
||||
content: payload[:content],
|
||||
author_id: @user.id,
|
||||
category_id: @category_id,
|
||||
status: :draft,
|
||||
meta: { source_urls: source_pages.pluck(:url) }
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scrape(urls)
|
||||
job = Firecrawl::Configuration.client.batch_scrape(
|
||||
urls,
|
||||
Firecrawl::Models::BatchScrapeOptions.new(options: Firecrawl::Configuration.default_scrape_options)
|
||||
)
|
||||
Array(job.data).filter_map { |doc| normalize(doc) }
|
||||
end
|
||||
|
||||
def normalize(doc)
|
||||
metadata = doc&.metadata || {}
|
||||
status = metadata['statusCode']
|
||||
return nil if status.present? && !(200..299).cover?(status)
|
||||
return nil if doc.markdown.to_s.blank?
|
||||
|
||||
{
|
||||
url: metadata['sourceURL'] || metadata['url'],
|
||||
markdown: doc.markdown.to_s,
|
||||
page_title: metadata['title'].to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def rewrite(source_pages)
|
||||
response = Captain::Llm::ArticleWriterService.new(
|
||||
account: @account,
|
||||
source_pages: source_pages,
|
||||
hint_title: @title.presence || source_pages.first[:page_title]
|
||||
).perform
|
||||
raise BuildFailed, "writer LLM error: #{response[:error]}" if response[:error]
|
||||
|
||||
payload = response[:message] || {}
|
||||
raise BuildFailed, 'writer returned blank content' if payload[:content].blank?
|
||||
raise BuildFailed, 'writer returned blank title' if payload[:title].blank?
|
||||
|
||||
payload
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
module Onboarding::HelpCenterBroadcaster
|
||||
ARTICLE_GENERATED = 'help_center.article_generated'.freeze
|
||||
GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def article_generated(user:, generation_id:, article:, articles_finished:)
|
||||
broadcast(user, ARTICLE_GENERATED, {
|
||||
generation_id: generation_id,
|
||||
article_id: article.id,
|
||||
articles_finished: articles_finished
|
||||
})
|
||||
end
|
||||
|
||||
def completed(user:, generation_id:, status:, skip_reason: nil)
|
||||
broadcast(user, GENERATION_COMPLETED, {
|
||||
generation_id: generation_id,
|
||||
status: status,
|
||||
skip_reason: skip_reason
|
||||
})
|
||||
end
|
||||
|
||||
def broadcast(user, event, payload)
|
||||
token = user&.pubsub_token
|
||||
return if token.blank?
|
||||
|
||||
ActionCableBroadcastJob.perform_later([token], event, payload)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
class Onboarding::HelpCenterCreationService
|
||||
DEFAULT_PORTAL_COLOR = '#1f93ff'.freeze
|
||||
LOGO_MAX_DOWNLOAD_SIZE = 5.megabytes
|
||||
|
||||
def initialize(account, user)
|
||||
@account = account
|
||||
@user = user
|
||||
end
|
||||
|
||||
def perform
|
||||
existing = existing_portal
|
||||
return reuse_existing_portal(existing) if existing
|
||||
|
||||
@account.portals.create!(portal_attributes).tap do |portal|
|
||||
attach_brand_logo(portal)
|
||||
enqueue_article_generation(portal)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def existing_portal
|
||||
@account.portals.first
|
||||
end
|
||||
|
||||
def reuse_existing_portal(portal)
|
||||
Rails.logger.info "[HelpCenterCreation] Reusing existing portal #{portal.id} for account #{@account.id}"
|
||||
portal
|
||||
end
|
||||
|
||||
def portal_attributes
|
||||
{
|
||||
name: portal_name,
|
||||
slug: generate_slug,
|
||||
color: portal_color,
|
||||
page_title: portal_name,
|
||||
header_text: header_text,
|
||||
homepage_link: homepage_link,
|
||||
channel_web_widget_id: web_widget_channel_id,
|
||||
config: { default_locale: locale, allowed_locales: [locale] }
|
||||
}.compact
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def portal_name
|
||||
brand_info[:title].presence || @account.name
|
||||
end
|
||||
|
||||
def portal_color
|
||||
hex = brand_info[:colors]&.first&.dig(:hex)
|
||||
hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_PORTAL_COLOR
|
||||
end
|
||||
|
||||
def header_text
|
||||
brand_info[:slogan].presence || brand_info[:description].presence
|
||||
end
|
||||
|
||||
def homepage_link
|
||||
with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
|
||||
end
|
||||
|
||||
def with_scheme(raw)
|
||||
return raw if raw.blank?
|
||||
|
||||
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
|
||||
end
|
||||
|
||||
def custom_attributes_website
|
||||
@account.custom_attributes['website']
|
||||
end
|
||||
|
||||
def enqueue_article_generation(portal)
|
||||
return if homepage_link.blank?
|
||||
|
||||
generation_id = SecureRandom.uuid
|
||||
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
def attach_brand_logo(portal)
|
||||
logo_url = brand_logo_url
|
||||
return if logo_url.blank?
|
||||
|
||||
SafeFetch.fetch(logo_url, max_bytes: LOGO_MAX_DOWNLOAD_SIZE, allowed_content_type_prefixes: ['image/']) do |logo_file|
|
||||
portal.logo.attach(
|
||||
io: logo_file.tempfile,
|
||||
filename: logo_file.original_filename,
|
||||
content_type: logo_file.content_type
|
||||
)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Logo attachment failed for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
def brand_logo_url
|
||||
Array(brand_info[:logos]).filter_map do |logo|
|
||||
logo.is_a?(Hash) ? logo[:url] : logo
|
||||
end.find(&:present?)
|
||||
end
|
||||
|
||||
def web_widget_channel_id
|
||||
@account.inboxes.find_by(channel_type: 'Channel::WebWidget')&.channel_id
|
||||
end
|
||||
|
||||
def locale
|
||||
@account.locale.presence || 'en'
|
||||
end
|
||||
|
||||
def generate_slug
|
||||
slug_candidates.find { |slug| !Portal.exists?(slug: slug) } || fallback_slug
|
||||
end
|
||||
|
||||
def slug_candidates
|
||||
base = @account.name.to_s.parameterize.presence
|
||||
return [] if base.blank?
|
||||
|
||||
first_token = base.split('-').first
|
||||
[base, first_token, "#{first_token}-docs", "#{first_token}-help"].uniq
|
||||
end
|
||||
|
||||
def fallback_slug
|
||||
base = @account.name.to_s.parameterize.presence || 'portal'
|
||||
"#{base}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
class Onboarding::HelpCenterCurator
|
||||
MAP_LIMIT = 500
|
||||
MAP_SEARCH = 'docs help support faq'.freeze
|
||||
MIN_ARTICLES = 3
|
||||
|
||||
Skipped = Onboarding::HelpCenterErrors::CurationSkipped
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def perform
|
||||
raise Skipped, 'Firecrawl not configured' unless Firecrawl::Configuration.configured?
|
||||
raise Skipped, 'no website url' if website_url.blank?
|
||||
|
||||
links = discover_links
|
||||
raise Skipped, 'map returned no links' if links.empty?
|
||||
|
||||
plan = curate(links)
|
||||
raise Skipped, "only #{plan[:articles].size} articles curated (< #{MIN_ARTICLES} threshold)" if plan[:articles].size < MIN_ARTICLES
|
||||
|
||||
plan.merge(allowed_urls: extract_urls(links)).deep_stringify_keys
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def discover_links
|
||||
data = Firecrawl::Configuration.client.map(
|
||||
website_url,
|
||||
Firecrawl::Models::MapOptions.new(limit: MAP_LIMIT, search: MAP_SEARCH)
|
||||
)
|
||||
Array(data.links)
|
||||
end
|
||||
|
||||
def extract_urls(links)
|
||||
Array(links).filter_map do |link|
|
||||
link['url'].presence
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def curate(links)
|
||||
response = Captain::Llm::HelpCenterCurationService.new(account: @account, links: links).perform
|
||||
raise Skipped, "curator LLM error: #{response[:error]}" if response[:error]
|
||||
|
||||
response[:message] || { categories: [], articles: [] }
|
||||
end
|
||||
|
||||
def website_url
|
||||
@website_url ||= with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
|
||||
end
|
||||
|
||||
def with_scheme(raw)
|
||||
return raw if raw.blank?
|
||||
|
||||
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
|
||||
end
|
||||
|
||||
def custom_attributes_website
|
||||
@account.custom_attributes['website']
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
module Onboarding::HelpCenterErrors
|
||||
class CurationSkipped < StandardError; end
|
||||
class ArticleBuildFailed < StandardError; end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class Onboarding::HelpCenterGenerationState
|
||||
# TODO: Reduce TTL to 48 hours once the full rollout is done
|
||||
TTL = 7.days.to_i
|
||||
|
||||
class Missing < StandardError; end
|
||||
|
||||
class << self
|
||||
def start(id, total:)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hset(key(id), 'status', 'generating', 'total', total.to_i, 'finished', 0)
|
||||
conn.expire(key(id), TTL)
|
||||
end
|
||||
end
|
||||
|
||||
def record_article_finished(id)
|
||||
Redis::Alfred.with do |conn|
|
||||
total = conn.hget(key(id), 'total')
|
||||
raise Missing, "missing state for generation #{id}" if total.blank?
|
||||
|
||||
finished = conn.hincrby(key(id), 'finished', 1)
|
||||
completed = finished >= total.to_i
|
||||
conn.hset(key(id), 'status', 'completed') if completed
|
||||
conn.expire(key(id), TTL)
|
||||
{ finished: finished, completed: completed }
|
||||
end
|
||||
end
|
||||
|
||||
def skip(id, reason:)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hset(key(id), 'status', 'skipped', 'skip_reason', reason.to_s)
|
||||
conn.expire(key(id), TTL)
|
||||
end
|
||||
end
|
||||
|
||||
def current(id)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hgetall(key(id)).presence
|
||||
end
|
||||
end
|
||||
|
||||
def key(id)
|
||||
format(Redis::Alfred::HELP_CENTER_GENERATION, id: id)
|
||||
end
|
||||
end
|
||||
end
|
||||
+19
-7
@@ -21,10 +21,26 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.get(key) }
|
||||
end
|
||||
|
||||
def with(&)
|
||||
$alfred.with(&)
|
||||
end
|
||||
|
||||
def delete(key)
|
||||
$alfred.with { |conn| conn.del(key) }
|
||||
end
|
||||
|
||||
# atomic compare-and-delete (release a lock only if you still own it); WATCH/MULTI
|
||||
# aborts the delete if the key changes between the check and the delete.
|
||||
def delete_if_equals(key, expected_value)
|
||||
$alfred.with do |conn|
|
||||
conn.watch(key) do
|
||||
next conn.unwatch unless conn.get(key) == expected_value
|
||||
|
||||
conn.multi { |transaction| transaction.del(key) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# increment a key by 1. throws error if key value is incompatible
|
||||
# sets key to 0 before operation if key doesn't exist
|
||||
def incr(key)
|
||||
@@ -111,13 +127,9 @@ module Redis::Alfred
|
||||
# add score and value for a key
|
||||
# Modern Redis syntax: zadd(key, [[score, member], ...])
|
||||
def zadd(key, score, value = nil)
|
||||
if value.nil? && score.is_a?(Array)
|
||||
# New syntax: score is actually an array of [score, member] pairs
|
||||
$alfred.with { |conn| conn.zadd(key, score) }
|
||||
else
|
||||
# Support old syntax for backward compatibility
|
||||
$alfred.with { |conn| conn.zadd(key, [[score, value]]) }
|
||||
end
|
||||
# New syntax: score is an array of [score, member] pairs; old syntax: discrete score/value
|
||||
pairs = value.nil? && score.is_a?(Array) ? score : [[score, value]]
|
||||
$alfred.with { |conn| conn.zadd(key, pairs) }
|
||||
end
|
||||
|
||||
# get score of a value for key
|
||||
|
||||
@@ -73,9 +73,12 @@ module Redis::RedisKeys
|
||||
# Track conversation assignments to agents for rate limiting
|
||||
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
|
||||
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
|
||||
# At-most-one AssignmentJob per inbox in-flight (queued or running); further enqueues are skipped
|
||||
AUTO_ASSIGNMENT_IN_FLIGHT_KEY = 'AUTO_ASSIGNMENT_IN_FLIGHT::%<inbox_id>d'.freeze
|
||||
|
||||
## Account Onboarding
|
||||
ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%<account_id>d'.freeze
|
||||
HELP_CENTER_GENERATION = 'HELP_CENTER_GENERATION::%<id>s'.freeze
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
|
||||
@@ -130,17 +130,27 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end
|
||||
|
||||
context 'when the failure is permanent' do
|
||||
# `discard_on PermanentCrawlError` swallows the error in `perform_now`
|
||||
# under normal conditions, but Zeitwerk reloading in CI can break the
|
||||
# rescue_handlers chain so the error escapes. The behavioural contract
|
||||
# we care about — no retries, correct document state — holds either
|
||||
# way, so tolerate both.
|
||||
def run_job
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
rescue StandardError => e
|
||||
# discard_on may have failed to swallow it; the contract still holds.
|
||||
raise unless e.class.name == 'Captain::Tools::SimplePageCrawlParserJob::PermanentCrawlError' # rubocop:disable Style/ClassEqualityComparison
|
||||
end
|
||||
|
||||
before do
|
||||
allow(crawler).to receive(:status_code).and_return(404)
|
||||
end
|
||||
|
||||
it 'does not retry a discovered link that was never persisted' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
it 'does not persist a discovered link that was never stored' do
|
||||
expect { run_job }.not_to change(assistant.documents, :count)
|
||||
end
|
||||
|
||||
it 'marks an existing document as available and failed without raising' do
|
||||
it 'marks an existing document as available and failed' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
@@ -150,9 +160,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
)
|
||||
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to raise_error
|
||||
run_job
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
status: 'available',
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Hello', 'urls' => ['https://x.test/a', 'https://evil.test/hallucinated'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'World', 'urls' => ['https://x.test/b'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
clear_enqueued_jobs
|
||||
curator = instance_double(Onboarding::HelpCenterCurator, perform: curated_plan)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).with(account: account).and_return(curator)
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'happy path' do
|
||||
it 'creates categories, starts state with total/finished, and fans out article payloads' do
|
||||
expect do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
end.to change { portal.categories.count }.by(1)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'generating', 'total' => '2', 'finished' => '0'
|
||||
)
|
||||
expect(enqueued_jobs).to include(
|
||||
a_hash_including(
|
||||
'job_class' => Onboarding::HelpCenterArticleWriterJob.name,
|
||||
'arguments' => array_including(
|
||||
account.id,
|
||||
portal.id,
|
||||
admin.id,
|
||||
generation_id,
|
||||
hash_including(
|
||||
'article' => hash_including(
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'orphan article filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Valid', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles whose category was not emitted alongside them' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Valid'))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'article URL filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Approved', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Hallucinated', 'urls' => ['https://evil.test/hallucinated'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles with no approved source urls before fanout' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
|
||||
)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'transaction rollback' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }]
|
||||
}
|
||||
end
|
||||
|
||||
it 'leaves zero categories and marks state skipped when no article can be stamped' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(portal.categories.count).to eq(0)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no articles after category or URL filtering'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'idempotency' do
|
||||
it 'no-ops when state already exists for this generation' do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to(change { portal.categories.count })
|
||||
expect(Onboarding::HelpCenterCurator).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'curation skipped' do
|
||||
it 'records skip_reason and transitions to skipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped', 'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'firecrawl retries' do
|
||||
it 'transitions to skipped after retries exhaust' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(Firecrawl::FirecrawlError, 'rate limited')
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
perform_enqueued_jobs { described_class.perform_later(*job_args) }
|
||||
|
||||
state = Onboarding::HelpCenterGenerationState.current(generation_id)
|
||||
expect(state['status']).to eq('skipped')
|
||||
expect(state['skip_reason']).to include('firecrawl exhausted')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,159 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
|
||||
let(:article_payload) { { 'article' => article_spec } }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
|
||||
before do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'success path' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'invokes the builder and increments the Redis counter' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
expect(Onboarding::HelpCenterArticleBuilder).to have_received(:new).with(
|
||||
account: account,
|
||||
portal: portal,
|
||||
user: admin,
|
||||
article: article_spec
|
||||
)
|
||||
end
|
||||
|
||||
it 'flips status to completed once the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'failure handling' do
|
||||
it 'increments the counter on ArticleBuildFailed without re-raising' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
|
||||
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
|
||||
it 're-enqueues itself on transient Firecrawl errors' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'transient'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(described_class).with(*job_args)
|
||||
end
|
||||
|
||||
it 'increments the counter when Firecrawl retries are exhausted' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'always failing'
|
||||
)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_later(*job_args)
|
||||
end
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.article_generated on success' do
|
||||
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.article_generated', payload)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.generation_completed when the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
|
||||
it 'does not broadcast article_generated on builder failure' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with(anything, 'help_center.article_generated', anything)
|
||||
end
|
||||
|
||||
it 'broadcasts generation_completed on late retries past total' do
|
||||
described_class.perform_now(*job_args)
|
||||
described_class.perform_now(*job_args)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
|
||||
end
|
||||
|
||||
it 'skips progress broadcasts when state is missing' do
|
||||
Redis::Alfred.delete(state_key)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
|
||||
describe 'source url validation' do
|
||||
it 'requires source urls' do
|
||||
article = { urls: [], title: 'X' }
|
||||
builder = described_class.new(account: account, portal: portal, user: user, article: article)
|
||||
|
||||
expect(Firecrawl::Configuration).not_to receive(:client)
|
||||
expect { builder.perform }
|
||||
.to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCreationService do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'user-confirmed.com' }) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
|
||||
before do
|
||||
allow(SecureRandom).to receive(:uuid).and_return(generation_id)
|
||||
end
|
||||
|
||||
describe 'article generation enqueue' do
|
||||
context 'when account has a custom_attributes website' do
|
||||
it 'enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has only a brand_info domain' do
|
||||
let(:account) { create(:account, custom_attributes: { 'brand_info' => { 'domain' => 'enrichment.com' } }) }
|
||||
|
||||
it 'uses the enrichment fallback and enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has no website url' do
|
||||
let(:account) { create(:account, custom_attributes: {}) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a portal already exists' do
|
||||
before { create(:portal, account_id: account.id) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal creation fails' do
|
||||
it 'raises the error' do
|
||||
allow(account.portals).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCurator do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'chatwoot.com' }) }
|
||||
let(:links) do
|
||||
[
|
||||
{ 'url' => 'https://chatwoot.com/docs/a', 'title' => 'A' },
|
||||
{ url: 'https://chatwoot.com/docs/b', title: 'B' },
|
||||
'https://chatwoot.com/docs/c'
|
||||
]
|
||||
end
|
||||
let(:llm_response) do
|
||||
{
|
||||
message: {
|
||||
categories: [{ name: 'Docs', description: 'Docs' }],
|
||||
articles: [
|
||||
{ title: 'A', urls: ['https://chatwoot.com/docs/a'], category_name: 'Docs' },
|
||||
{ title: 'B', urls: ['https://chatwoot.com/docs/b'], category_name: 'Docs' },
|
||||
{ title: 'C', urls: ['https://chatwoot.com/docs/c'], category_name: 'Docs' }
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
firecrawl_client = instance_double(Firecrawl::Client, map: instance_double(Firecrawl::Models::MapData, links: links))
|
||||
llm_service = instance_double(Captain::Llm::HelpCenterCurationService, perform: llm_response)
|
||||
|
||||
allow(Firecrawl::Configuration).to receive(:configured?).and_return(true)
|
||||
allow(Firecrawl::Configuration).to receive(:client).and_return(firecrawl_client)
|
||||
allow(Captain::Llm::HelpCenterCurationService).to receive(:new)
|
||||
.with(account: account, links: links)
|
||||
.and_return(llm_service)
|
||||
end
|
||||
|
||||
it 'extracts allowed urls from Firecrawl string-keyed link hashes' do
|
||||
result = described_class.new(account: account).perform
|
||||
|
||||
expect(result['allowed_urls']).to eq(['https://chatwoot.com/docs/a'])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterGenerationState do
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:account_id) { 42 }
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(described_class.key(generation_id))
|
||||
end
|
||||
|
||||
describe '.start' do
|
||||
it 'stores status, total, finished, and sets a ttl' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
Redis::Alfred.with do |conn|
|
||||
expect(conn.hget(described_class.key(generation_id), 'status')).to eq('generating')
|
||||
expect(conn.hget(described_class.key(generation_id), 'total')).to eq('2')
|
||||
expect(conn.hget(described_class.key(generation_id), 'finished')).to eq('0')
|
||||
expect(conn.ttl(described_class.key(generation_id))).to be_positive
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.record_article_finished' do
|
||||
it 'increments finished and keeps completed true past the final count' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 1, completed: false)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 2, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '2')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 3, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '3')
|
||||
end
|
||||
|
||||
it 'raises Missing when no state exists for the generation' do
|
||||
expect { described_class.record_article_finished(generation_id) }
|
||||
.to raise_error(described_class::Missing)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.skip' do
|
||||
it 'stores status and reason' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
described_class.skip(generation_id, reason: 'no website url')
|
||||
|
||||
expect(described_class.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.current' do
|
||||
it 'returns nil when no state exists' do
|
||||
expect(described_class.current(generation_id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :channel_app_store, class: 'Channel::AppStore' do
|
||||
account
|
||||
app_id { SecureRandom.random_number(1_000_000_000..9_999_999_999).to_s }
|
||||
bundle_id { 'com.example.app' }
|
||||
app_name { 'Example App' }
|
||||
issuer_id { SecureRandom.uuid }
|
||||
key_id { SecureRandom.alphanumeric(10).upcase }
|
||||
private_key do
|
||||
key = OpenSSL::PKey::EC.generate('prime256v1')
|
||||
key.to_pem
|
||||
end
|
||||
|
||||
to_create { |instance| instance.save!(validate: false) }
|
||||
|
||||
after(:create) do |channel|
|
||||
create(:inbox, channel: channel, account: channel.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,10 +24,11 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_return(3)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
|
||||
end
|
||||
|
||||
it 'uses custom bulk limit from environment' do
|
||||
@@ -67,16 +68,40 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect do
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end.to raise_error(StandardError, 'Something went wrong')
|
||||
|
||||
expect(Rails.logger).to have_received(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.enqueue_for_inbox' do
|
||||
after { Redis::Alfred.delete(format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)) }
|
||||
|
||||
it 'enqueues one run per inbox and coalesces concurrent triggers' do
|
||||
allow(described_class).to receive(:perform_later).and_return(true)
|
||||
|
||||
expect(described_class.enqueue_for_inbox(inbox.id)).to be(true)
|
||||
expect(described_class.enqueue_for_inbox(inbox.id)).to be(false)
|
||||
expect(described_class).to have_received(:perform_later).once
|
||||
end
|
||||
|
||||
it 'does not release a newer run marker when its own token is stale' do
|
||||
key = format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)
|
||||
Redis::Alfred.set(key, 'newer-token', ex: 300)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new)
|
||||
.and_return(instance_double(AutoAssignment::AssignmentService, perform_bulk_assignment: 0))
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id, token: 'stale-token')
|
||||
|
||||
expect(Redis::Alfred.get(key)).to eq('newer-token')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'is queued in the default queue' do
|
||||
expect(described_class.queue_name).to eq('default')
|
||||
|
||||
@@ -29,7 +29,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
|
||||
it 'queues assignment job for eligible inboxes' do
|
||||
inbox_assignment_policy # ensure it exists
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -51,8 +51,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
|
||||
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox2.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -65,7 +65,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not queue assignment job' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -78,7 +78,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not process the account' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:suspended_account) { create(:account, status: 'suspended') }
|
||||
let(:due_channel) { create(:channel_app_store, account: account, last_synced_at: 2.hours.ago) }
|
||||
let(:fresh_channel) { create(:channel_app_store, account: account, last_synced_at: 10.minutes.ago) }
|
||||
let(:suspended_channel) { create(:channel_app_store, account: suspended_account, last_synced_at: 2.hours.ago) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later }.to have_enqueued_job(described_class)
|
||||
.on_queue('scheduled_jobs')
|
||||
end
|
||||
|
||||
it 'enqueues fetch jobs only for due channels on active accounts' do
|
||||
due_channel
|
||||
fresh_channel
|
||||
suspended_channel
|
||||
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).to receive(:perform_later).with(due_channel).once
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(fresh_channel)
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(suspended_channel)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchAppStoreReviewsJob do
|
||||
let(:channel) { create(:channel_app_store, last_synced_at: nil) }
|
||||
let(:review_payload) { { 'review' => { 'id' => 'review-1' }, 'response' => nil } }
|
||||
let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(channel) }.to have_enqueued_job(described_class)
|
||||
.with(channel)
|
||||
.on_queue('scheduled_jobs')
|
||||
end
|
||||
|
||||
it 'fetches reviews, builds messages, and updates the sync timestamp' do
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).with(review_payload: review_payload, channel: channel).and_return(review_builder)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(review_builder).to have_received(:perform)
|
||||
expect(channel.reload.last_synced_at).to be_present
|
||||
end
|
||||
|
||||
it 'captures per-review errors and continues syncing' do
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
|
||||
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(exception_tracker).to have_received(:capture_exception)
|
||||
expect(channel.reload.last_synced_at).to be_present
|
||||
end
|
||||
end
|
||||
@@ -122,5 +122,11 @@ RSpec.describe SendReplyJob do
|
||||
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
|
||||
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
|
||||
end
|
||||
|
||||
it 'calls ::AppStore::SendOnAppStoreService when its app store message' do
|
||||
app_store_channel = create(:channel_app_store)
|
||||
message = create(:message, conversation: create(:conversation, inbox: app_store_channel.inbox))
|
||||
expect_mapped_service_to_perform(message, 'AppStore::SendOnAppStoreService')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::AppStore do
|
||||
describe 'validations' do
|
||||
it 'normalizes auth fields and stores app metadata from App Store Connect' do
|
||||
app_store_client = instance_double(AppStoreConnect::Client)
|
||||
channel = build(
|
||||
:channel_app_store,
|
||||
account: create(:account),
|
||||
app_id: ' 123456789 ',
|
||||
issuer_id: ' issuer-id ',
|
||||
key_id: ' key-id ',
|
||||
private_key: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\r\n",
|
||||
app_name: nil,
|
||||
bundle_id: nil
|
||||
)
|
||||
|
||||
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||
allow(app_store_client).to receive(:fetch_app).and_return(
|
||||
{
|
||||
'attributes' => {
|
||||
'name' => 'Chatwoot iOS',
|
||||
'bundleId' => 'com.chatwoot.app'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(channel).to be_valid
|
||||
expect(channel.app_id).to eq('123456789')
|
||||
expect(channel.issuer_id).to eq('issuer-id')
|
||||
expect(channel.key_id).to eq('key-id')
|
||||
expect(channel.private_key).to eq("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----")
|
||||
expect(channel.app_name).to eq('Chatwoot iOS')
|
||||
expect(channel.bundle_id).to eq('com.chatwoot.app')
|
||||
end
|
||||
|
||||
it 'adds an error when App Store Connect validation fails' do
|
||||
app_store_client = instance_double(AppStoreConnect::Client)
|
||||
channel = build(:channel_app_store)
|
||||
|
||||
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||
allow(app_store_client).to receive(:fetch_app).and_raise(AppStoreConnect::Client::Error, 'invalid credentials')
|
||||
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:base]).to include('invalid credentials')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_due?' do
|
||||
it 'returns true when the channel has never synced' do
|
||||
channel = build(:channel_app_store, last_synced_at: nil)
|
||||
|
||||
expect(channel.sync_due?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when the last sync is within the sync interval' do
|
||||
channel = build(:channel_app_store, last_synced_at: 30.minutes.ago)
|
||||
|
||||
expect(channel.sync_due?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStore::ReviewBuilder do
|
||||
let(:channel) { create(:channel_app_store) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:review_payload) do
|
||||
{
|
||||
'review' => {
|
||||
'id' => 'review-1',
|
||||
'attributes' => {
|
||||
'rating' => 4,
|
||||
'title' => 'Helpful app',
|
||||
'body' => 'Works well for support.',
|
||||
'territory' => 'US',
|
||||
'reviewerNickname' => 'Reviewer',
|
||||
'createdDate' => '2026-05-20T10:00:00-00:00'
|
||||
},
|
||||
'relationships' => {
|
||||
'response' => {
|
||||
'data' => {
|
||||
'id' => 'response-1',
|
||||
'type' => 'customerReviewResponses'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'response' => {
|
||||
'id' => 'response-1',
|
||||
'attributes' => {
|
||||
'responseBody' => 'Thanks for the feedback.',
|
||||
'state' => 'PUBLISHED',
|
||||
'lastModifiedDate' => '2026-05-20T11:00:00-00:00'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates a conversation with an incoming review and outgoing developer response' do
|
||||
expect { described_class.new(review_payload: review_payload, channel: channel).perform }
|
||||
.to change(inbox.conversations, :count).by(1)
|
||||
.and change(Message.where(inbox_id: inbox.id), :count).by(2)
|
||||
|
||||
conversation = inbox.conversations.last
|
||||
review_message = conversation.messages.incoming.find_by(source_id: 'review-1')
|
||||
response_message = conversation.messages.outgoing.find_by(source_id: 'response-1')
|
||||
|
||||
expect(conversation.contact_inbox.source_id).to eq('review-1')
|
||||
expect(review_message.content).to include('★★★★☆ (4/5)', 'Helpful app', 'Works well for support.', 'US • Reviewer')
|
||||
expect(review_message.content_attributes['app_store']).to include(
|
||||
'rating' => 4,
|
||||
'title' => 'Helpful app',
|
||||
'territory' => 'US',
|
||||
'reviewer_nickname' => 'Reviewer'
|
||||
)
|
||||
expect(response_message.content).to eq('Thanks for the feedback.')
|
||||
expect(response_message.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'updates an existing review message when Apple returns the same review again' do
|
||||
described_class.new(review_payload: review_payload, channel: channel).perform
|
||||
updated_payload = review_payload.deep_dup
|
||||
updated_payload['review']['attributes']['body'] = 'Updated review body.'
|
||||
|
||||
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
|
||||
.not_to change(Message.where(inbox_id: inbox.id), :count)
|
||||
|
||||
expect(inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1').content).to include('Updated review body.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStore::SendOnAppStoreService do
|
||||
let(:channel) { create(:channel_app_store) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: inbox.account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'review-1') }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, account: inbox.account) }
|
||||
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||
|
||||
before do
|
||||
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates an App Store response for a new reply' do
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
|
||||
|
||||
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(channel).to have_received(:reply_to_review).with('review-1', 'Thanks', response_id: nil)
|
||||
expect(message.reload.source_id).to eq('response-1')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'updates the existing App Store response when the conversation already has one' do
|
||||
create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Old reply',
|
||||
source_id: 'response-1')
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Updated reply')
|
||||
|
||||
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(channel).to have_received(:reply_to_review).with('review-1', 'Updated reply', response_id: 'response-1')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'marks the message as failed when attachments are present' do
|
||||
message = create(:message, :with_attachment, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(
|
||||
message,
|
||||
'failed',
|
||||
'Sending attachments is not supported for App Store reviews.'
|
||||
)
|
||||
expect(exception_tracker).to have_received(:capture_exception)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStoreConnect::Client do
|
||||
let(:channel) { create(:channel_app_store, app_id: '123456789') }
|
||||
let(:token_service) { instance_double(AppStoreConnect::TokenService, token: 'jwt-token') }
|
||||
|
||||
before do
|
||||
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(token_service)
|
||||
end
|
||||
|
||||
describe '#fetch_app' do
|
||||
it 'fetches the configured app' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||
.with(headers: { 'Authorization' => 'Bearer jwt-token' })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
id: '123456789',
|
||||
attributes: {
|
||||
name: 'Chatwoot',
|
||||
bundleId: 'com.chatwoot.app'
|
||||
}
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect(described_class.new(channel: channel).fetch_app['id']).to eq('123456789')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_reviews' do
|
||||
it 'fetches reviews and attaches the included developer response' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
|
||||
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: 'review-1',
|
||||
type: 'customerReviews',
|
||||
relationships: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
included: [
|
||||
{
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: 'Thanks for the review'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
review_payload = described_class.new(channel: channel).fetch_reviews.first
|
||||
|
||||
expect(review_payload['review']['id']).to eq('review-1')
|
||||
expect(review_payload['response']['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_review_response' do
|
||||
it 'creates a response for a review' do
|
||||
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
|
||||
.with(
|
||||
body: {
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: 'Thanks'
|
||||
},
|
||||
relationships: {
|
||||
review: {
|
||||
data: {
|
||||
type: 'customerReviews',
|
||||
id: 'review-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 201,
|
||||
body: { data: { id: 'response-1' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
response = described_class.new(channel: channel).create_review_response('review-1', 'Thanks')
|
||||
|
||||
expect(response['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_review_response' do
|
||||
it 'updates an existing response' do
|
||||
stub_request(:patch, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses/response-1')
|
||||
.with(
|
||||
body: {
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
id: 'response-1',
|
||||
attributes: {
|
||||
responseBody: 'Updated response'
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: { id: 'response-1' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
response = described_class.new(channel: channel).update_review_response('response-1', 'Updated response')
|
||||
|
||||
expect(response['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises a useful error when Apple returns an error response' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||
.to_return(
|
||||
status: 401,
|
||||
body: {
|
||||
errors: [
|
||||
{
|
||||
detail: 'Provide a properly configured and signed bearer token.'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect { described_class.new(channel: channel).fetch_app }
|
||||
.to raise_error(AppStoreConnect::Client::Error, /properly configured and signed bearer token/)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStoreConnect::TokenService do
|
||||
let(:private_key) { OpenSSL::PKey::EC.generate('prime256v1').to_pem }
|
||||
let(:channel) do
|
||||
instance_double(
|
||||
Channel::AppStore,
|
||||
id: 1,
|
||||
updated_at: Time.zone.at(1_700_000_000),
|
||||
issuer_id: 'issuer-id',
|
||||
key_id: 'key-id',
|
||||
private_key: private_key
|
||||
)
|
||||
end
|
||||
|
||||
describe '#token' do
|
||||
it 'generates an App Store Connect JWT with the expected claims and headers' do
|
||||
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
|
||||
token = described_class.new(channel: channel).token
|
||||
payload, header = JWT.decode(token, nil, false)
|
||||
|
||||
expect(payload).to include(
|
||||
'iss' => 'issuer-id',
|
||||
'iat' => Time.current.to_i,
|
||||
'exp' => 19.minutes.from_now.to_i,
|
||||
'aud' => 'appstoreconnect-v1'
|
||||
)
|
||||
expect(header).to include('kid' => 'key-id', 'typ' => 'JWT', 'alg' => 'ES256')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns cached tokens for the channel' do
|
||||
allow(Rails.cache).to receive(:read).with('app_store_connect_token:1:1700000000').and_return('cached-token')
|
||||
|
||||
expect(described_class.new(channel: channel).token).to eq('cached-token')
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user