Task Breakdown — Company Token (Omnichannel side)
RFC: channel-integration-company-token.md (rev 4)
Repos (verified locally): hub_core = /Users/mekari/chatpanel/hub_core · hub_service = /Users/mekari/chatpanel/hub_service
Slicing: by §4.C execution chunks (backend). No FE tasks — the chat web UI is a separate FE RFC (§1 Out of Scope), so no design references apply.
Test command: bundle exec rspec / bundle exec rubocop --no-color (hub_core bin/overcommit_run for the full gate); hub_service bundle exec rspec.
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Task 1 — Eligibility service + activity-log helper | — | 1 | — | 1 |
| Task 2 — Connect flow (eligibility + checked save + connected_at + timeouts) | — | 2 | 0.5 | 2.5 |
| Task 3 — Interactor emit + builder credential-strip | — | 2 | 0.5 | 2.5 |
| Task 4 — Regression + lint gates | — | 0.5 | 0.5 | 1 |
| Grand total | — | 5.5 | 1.5 | 7 |
Confidence: high. All anchor files verified on disk; the RFC pins line-level targets and the connect branch already exists (pre-phase). Biggest movable: the ineligible-cohort check (REV-9) could add grandfather/migrate work before Stage-2 enablement, and the exact placement/shape of the log-helper PORO is the dev's call (doesn't move the estimate).
Task 1: [BE] Eligibility service + activity-log emit helper (CTOK-S01, CTOK-S09)
A connecting org is routed to the company-token branch by real eligibility (Qontak One /
unified_app), and connect/disconnect outcomes are emitted as structured log events — with no new datastore.
Status: ✅ Actionable
What to build
Two small POROs under hub_core/app/apps/crm/services/: a stateless eligibility predicate wrapping the verified billing GetUnified service, and a thin CustomLogFormat wrapper that emits the §2.3 activity events (connect_succeeded / connect_failed / disconnected). No model, no repository, no migration.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/crm/services/company_token_eligibility.rb | call(organization) → bool: `organization.unified_sso_id.present? |
| create | hub_core/app/apps/crm/services/company_token_eligibility_spec.rb | sso-org → true; unified_app-org → true; neither → false; billing-nil → false + warning |
| create | hub_core/app/apps/crm/services/integration_activity_log.rb | .emit(event_type:, organization_id:, actor_id: nil, reason: nil, billing_cid: nil, company_id: nil, duration_ms: nil) wrapping CustomLogFormat; never raises; no email/token in payload |
| create | hub_core/app/apps/crm/services/integration_activity_log_spec.rb | asserts each event_type emits with the §2.3 fields; rejects email/credential keys |
Implementation steps
- Explore — open
hub_core/app/apps/crm/services/moderator/apis.rb:10-17for theCustomLogFormat.new(error:, message:, class_name:, method_name:, args:)shape, andapp/apps/billings/services/v2/redis/subscriptions/get_unified.rbfor the nil-on-failure contract the eligibility PORO depends on. - Red — write
company_token_eligibility_spec.rbandintegration_activity_log_spec.rbcovering the ACs; run and confirm they fail. - Scaffold — create both service files with
call/emitsignatures, no logic. - Implement — eligibility predicate (sso OR
unified_app, nil→false+warning); log helper builds the structuredargshash and callsCustomLogFormat. - Green —
bundle exec rspec spec/apps/crm/services/company_token_eligibility_spec.rb spec/apps/crm/services/integration_activity_log_spec.rb. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
- Eligibility:
unified_sso_idpresent → true;unified_apptrue → true; neither → false;GetUnifiednil → false +CustomLogFormatwarning. - Log helper emits
connect_succeeded/connect_failed/disconnectedwithevent_type,organization_id,actor_id,reason,billing_cid,company_id,duration_ms. - Helper never raises; payload contains no
actor_email, token, or credential.
Test strategy
Stub GetUnified to return {'unified_app'=>true}, {}, and nil; assert routing bool + the warning on nil. For the helper, spy on CustomLogFormat and assert the emitted args keys/values; assert no email/credential key present.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | — |
| Total | 1 |
Assumptions: reuses verified
GetUnified; helper wraps existingCustomLogFormat; internal-only (no user-facing behavior → QA 0).
Run to verify
cd /Users/mekari/chatpanel/hub_core && bundle exec rspec spec/apps/crm/services/company_token_eligibility_spec.rb spec/apps/crm/services/integration_activity_log_spec.rb && bundle exec rubocop --no-color
Depends on
- None.
Task 2: [BE] Connect flow — eligibility + checked save + connected_at + timeouts (CTOK-S01, CTOK-S05-NEG)
An eligible org's connect goes through the company-token branch and can never silently report success on a failed persist; the connected date is recorded and CRM calls are bounded by a timeout.
Status: ✅ Actionable
What to build
Modify the existing Qontak::Create repository: swap the flag-only branch condition for flags && CompanyTokenEligibility, set settings[:connected_at], replace the silent save rescue nil with a checked save, and add timeout: 10 to the CRM calls.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/repositories/channel_integrations/qontak/create.rb | L12 branch → flags && Crm::Services::CompanyTokenEligibility.new.call(organization); set settings[:connected_at] = Time.now.utc.iso8601 in the company-token branch; L152 integration.save rescue nil → return failure error_messages_for(integration) unless integration.save (pattern apis.rb:119) |
| extend | hub_core/app/apps/crm/services/team/apis.rb | pass timeout: 10 on the teams/company_token (+ /refresh) POSTs |
| extend | hub_core/app/core/domains/repositories/channel_integrations/qontak/create_spec.rb | ineligible→legacy; failed save→Failure (no success path); success→connected_at present; timeout kwarg asserted |
Implementation steps
- Explore — re-read
qontak/create.rbL12 (branch), L134-155 (create_or_restore_channel, thesave rescue nilat L152), andapis.rb:119for theerror_messages_forchecked-save precedent. - Red — extend
create_spec.rb: ineligible-org routes to legacy; a save-failure stub returnsFailureand never hits the success builder; success setsconnected_at. - Implement — wire
CompanyTokenEligibilityat L12; setconnected_at; replace L152 with the checked save; addtimeout: 10inteam/apis.rb. - Green —
bundle exec rspec spec/core/domains/repositories/channel_integrations. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
- Ineligible org (no
unified_sso_id, nounified_app) → legacy branch. - Failed
integration.save→Failurewith AR messages, no success path executed (closes thesave rescue nilhole). - Successful company-token connect sets
settings['connected_at'](ISO-8601 UTC). - CRM
teams/company_tokencalls carrytimeout: 10.
Test strategy
Stub CompanyTokenEligibility both ways; stub the AR model so save returns false and assert the monad short-circuits to Failure before success Builders::…. Assert connected_at on the happy path and the timeout: 10 kwarg on the CRM call.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: company-token branch already exists (pre-phase); checked-save pattern is in-repo (
apis.rb:119); no schema change.
Run to verify
cd /Users/mekari/chatpanel/hub_core && bundle exec rspec spec/core/domains/repositories/channel_integrations && bundle exec rubocop --no-color
Depends on
- Task 1 (
CompanyTokenEligibility).
Task 3: [BE] Interactor emit + builder credential-strip (CTOK-S05, CTOK-S07, CTOK-S09)
Connect/disconnect outcomes are logged as activity events with the acting admin's id, and the encrypted credentials blob stops leaking into qontak integration API responses.
Status: ✅ Actionable
What to build
Wire the Task-1 log helper into the connect/disconnect interactors (emit at the interactor layer, actor kept out of the repo params), scope disconnect logging to qontak via target_channel, and add settings.delete('credentials') in the qontak builder branch. Pass me.id from hub_service.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/interactors/channel_integrations/user_connects_qontak.rb | optional(:actor_id).maybe(:string); after repo call emit connect_succeeded(actor_id, billing_cid, company_id, duration_ms) / connect_failed(reason); do not pass actor into Qontak::Create |
| extend | hub_core/app/core/domains/interactors/channel_integrations/user_disconnects_channel.rb | optional(:actor_id); emit disconnected only when result.success.target_channel == 'qontak' |
| extend | hub_core/app/core/domains/builders/channel_integration.rb | qontak branch: settings.delete('credentials') (precedent: settings.delete('access_token') for meta_ad_account) |
| extend | hub_service/app/services/api/core/v1/integrations/resources/integrations.rb | connect/disconnect blocks pass me.id as actor_id |
| extend | *_spec.rb (both interactors + builder + hub_service request spec) | emit assertions, credential-absence, qontak-scoping |
Implementation steps
- Explore — open both interactors,
builders/channel_integration.rb(find thesettings.delete('access_token')line +settings: settingspassthrough), andentities/channel_integration.rb(confirmtarget_channel). - Red — specs: connect success emits
connect_succeededw/billing_cid; failure emitsconnect_failedw/ reason; disconnect(qontak) emitsdisconnected, disconnect(telegram) emits none; uniq/connect response has nocredentialskey. - Implement — add contract fields, call
IntegrationActivityLog.emit(...), slice actor before repo delegation; add the builderdelete; passme.idin hub_service. - Green — hub_core
bundle exec rspec spec/core/domains/interactors/channel_integrations spec/core/domains/builders; hub_servicebundle exec rspec spec/services/api/core/v1. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
- uniq/connect responses contain the four detail fields and no
credentialskey. - Connect success emits
connect_succeededwithbilling_cid+actor_id; failure emitsconnect_failedwith reason. - Disconnect of a qontak channel emits
disconnectedwithactor_id; a non-qontak disconnect emits nothing. -
Qontak::Createparams are unchanged — actor identity never enters the repo.
Test strategy
Spy on IntegrationActivityLog.emit; assert event_type + fields per path. Build the qontak entity and assert settings['credentials'] absent while a non-qontak channel keeps its settings. hub_service request spec asserts response shape + actor_id passed.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: builder stripping has an in-file precedent; disconnect entity exposes
target_channel; logging is interactor-layer (closes REV-3).
Run to verify
cd /Users/mekari/chatpanel/hub_core && bundle exec rspec spec/core/domains/interactors/channel_integrations spec/core/domains/builders && \
cd /Users/mekari/chatpanel/hub_service && bundle exec rspec spec/services/api/core/v1
Depends on
- Task 1 (log helper), Task 2 (connect flow provides
billing_cid/duration_mscontext).
Task 4: [BE] Regression + lint gates (CTOK-S06)
The existing CRM auto-create / owner / association / auto-resolve behavior is unchanged, and both repos pass their full suites.
Status: ✅ Actionable
What to build
No new code — run the full gates and fix any regressions surfaced by Tasks 1–3.
Implementation steps
- hub_core:
bundle exec rubocop --no-color && bundle exec rspec(orbin/overcommit_runfor lint+tests+Brakeman). - hub_service:
bundle exec rspec. - Fix any failures; re-run until green.
Acceptance criteria
- hub_core full suite + rubocop exit 0 (
bin/overcommit_rungreen). - hub_service full suite exit 0.
- CRM app suite (
spec/apps/crm) shows no behavioral regression.
Test strategy
Whole-suite regression; the CRM app suite is the guard for CTOK-S06 "keep existing functions". QA does an exploratory pass on connect / disconnect / auto-create.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
Assumptions: pure gate/regression; QA is exploratory pass on connect/disconnect + auto-create.
Run to verify
cd /Users/mekari/chatpanel/hub_core && bin/overcommit_run && cd /Users/mekari/chatpanel/hub_service && bundle exec rspec
Depends on
- Tasks 1–3.
Ordering rationale
- Task 1 first — the eligibility predicate and log helper are pure, dependency-free, and unblock everything else.
- Task 2 is the critical path — it carries the write-integrity fix (checked save) and the branch selection; Task 3's emit relies on the
billing_cid/duration_msit surfaces. - Task 3 depends on 1 + 2 — it's the observability + security-surface layer on top of a correct connect flow.
- Task 4 last — gates the whole change; the CRM regression suite is the guard for the "keep existing functions" story.
- External push: run the REV-9 cohort check (orgs flagged in
:company_token_specificthat lackunified_sso_id/unified_app) before enabling Stage 2 in production — it's a pre-merge query, not a coding task, but it gates rollout.
Skipped stories
| Story | Reason |
|---|---|
| CTOK-S02 | BE already shipped (username optional + me.email fallback); only the FE email-field removal remains → FE RFC |
| CTOK-S03 | Reporter/deal-owner is CRM-side + FE; chat BE endpoint (/qontak/crm/users) reused unchanged |
| CTOK-S04 | Auto-connect / system-connect endpoint descoped (rev 3, §1 Out of Scope); CRM half owned by CRM squad |
| CTOK-S08 | Menu rename — FE-only |