Spec: F44 — API Integrations (SME CEO-Grade Connectors)
Status: shipped
1. Problem & CEO Job
Job: See clearly — but external signals are invisible until someone reports them. A late-cleared transaction, damaged shipment, 30-day-overdue invoice, or unanswered Zalo complaint can wake a CEO at night without ever appearing in a daily pulse.
F33 provides the generic webhook framework; F44 applies it to the systems Vietnamese SME CEOs run. The promise is not “connect your tools,” but “Lumi notices before you ask.” Every connector ships with at least one CEO-recognizable exception from an ordinary Tuesday morning.
2. User Stories & Acceptance Criteria
Phase 1 — Must-Ship (Launch Partner Connectors)
Each acceptance criterion below is testable and maps to at least one test case in §7.
Zalo OA Connector
- As a CEO, I want Lumi to watch my Zalo Official Account, so that customer messages that go unanswered for 2+ hours become a risk in my brief.
- Given an admin with valid Zalo OA credentials (OA ID + access token + refresh token), when they connect via Admin UI, then
SourceConnectionis created withprovider_slug=zalo_oa,status=active, andconfig_jsonstores{oa_id, access_token, refresh_token, app_id}. - Given an active Zalo OA connection, when the poll task runs (every 5 minutes), then messages are fetched from Zalo API and new unanswered messages create
SourceEventrecords withcapture_channel=connector. - Given a Zalo customer message with no reply within 2 hours, then
SourceEventhasseverity=mediumand routes toSignalInboxItemvia the default ruleevent_type=message_unanswered. - Given a Zalo customer message with no reply within 6 hours, then
severity=high. - Given a Zalo customer message with no reply within 24 hours, then
severity=critical. - Given a Zalo connection exists, when an admin views
/settings/sources, then the connection card shows status indicator (active/degraded/disabled), last polled time, and message counts. - Given the Zalo API returns a 401, then connector transitions to
degradedandlast_error_textis set.
- Given an admin with valid Zalo OA credentials (OA ID + access token + refresh token), when they connect via Admin UI, then
MISA Connector
- As a CEO, I want Lumi to surface overdue invoices from my accounting system, so that I stop discovering cash problems at month-end.
- Given an admin with valid MISA API credentials (company tax code + API key + API secret), when they connect, then
SourceConnectionis created withprovider_slug=misa,status=active. - Given an active MISA connection, when the poll task runs, then invoices 15+ days past due create
SourceEventrecords withevent_type=invoice_overdue,proposal_type=risk. - Given an invoice 30+ days past due, then the resulting
SourceEventhasseverity=critical. - Given MISA API returns a 429, then connector applies exponential backoff (60s→120s→240s) and retries up to 3 times before marking
degradedfor this poll cycle.
- Given an admin with valid MISA API credentials (company tax code + API key + API secret), when they connect, then
Fast Accounting Connector
- As a CEO, I want Lumi to surface overdue invoices from Fast Accounting, so that cash-flow problems are surfaced regardless of accounting platform.
- Given an admin with valid Fast Accounting credentials, when they connect, then
SourceConnectionis created withprovider_slug=fast_accounting,status=active. - Given an active Fast Accounting connection, when the poll task runs, then invoices 15+ days past due create
SourceEventrecords withevent_type=invoice_overdue,proposal_type=risk. - Given Fast Accounting API is down (5xx for 3 consecutive poll cycles), then connector transitions to
degradedandretry_countresets after a successful poll.
- Given an admin with valid Fast Accounting credentials, when they connect, then
Shopify Connector
- As a CEO, I want Lumi to flag stalled e-commerce orders, so that fulfillment problems don’t fester until the customer cancels.
- Given an admin with a Shopify shop, when they initiate OAuth (scoped to
read_orders), then Lumi redirects to Shopify OAuth, receives the callback, stores the access token, and createsSourceConnectionwithprovider_slug=shopify,status=active. - Given an active Shopify connection, when the poll task runs, then orders with
fulfillment_status=unfulfilledfor 48+ hours createSourceEventwithevent_type=order_unfulfilled,proposal_type=task. - Given a refund or chargeback is detected on a Shopify order, then
SourceEventis created withevent_type=refund_received,proposal_type=risk. - Given Shopify webhook is registered during
connect(), when an order event fires, then Lumi receives it atPOST /api/source-connections/webhook/<secret>/and processes it.
- Given an admin with a Shopify shop, when they initiate OAuth (scoped to
Haravan Connector
- As a CEO, I want Lumi to flag stalled orders on Haravan, so that e-commerce fulfillment gaps are caught regardless of platform.
- Given an admin with a Haravan shop, when they initiate OAuth (scoped to orders), then Lumi completes the OAuth flow and creates
SourceConnectionwithprovider_slug=haravan,status=active. - Given an active Haravan connection, when the poll task runs, then orders unfulfilled for 48+ hours create
SourceEventwithevent_type=order_unfulfilled,proposal_type=task. - Given two identical Haravan webhook events arrive within 5 seconds, then the second is silently dropped (dedup key
(organization_id, connection_id, external_event_id)).
- Given an admin with a Haravan shop, when they initiate OAuth (scoped to orders), then Lumi completes the OAuth flow and creates
Google Workspace Connector
- As a CEO running a service business, I want Google Workspace integration so Lumi can parse project updates from shared docs and Sheets, so that the brief includes what teams already wrote instead of asking them to write it twice.
- Given a Google Workspace admin configures a service account with domain-wide delegation (Drive + Sheets read-only scopes), when they connect, then
SourceConnectionis created withprovider_slug=google_workspace,status=active. - Given an active Google Workspace connection, when an admin selects monitored Drive folder IDs and Sheet IDs, then those IDs are stored in
config_json.monitored_folder_idsandconfig_json.monitored_sheet_ids. - Given a monitored Google Sheet, when new rows are added, then the next poll creates
SourceEventwith the row data asbody_text,event_type=sheet_row_added,proposal_type=commitment. - Given a monitored Drive folder, when a doc is created or edited, then the next poll creates
SourceEventwith document title + excerpt asbody_text,event_type=doc_modified,proposal_type=decision. - Given the Google Workspace service account token expires, when the token refresh fails, then connector transitions to
degradedwithlast_error_textindicating auth failure. Historical events are preserved.
- Given a Google Workspace admin configures a service account with domain-wide delegation (Drive + Sheets read-only scopes), when they connect, then
Phase 2 — Platform Expansion
-
As a CEO, I want Lumi to ingest bank transaction alerts, so that I see unusual cash movements without logging into internet banking.
- Connect via VietQR webhook (Sepay already supported for inbound payments — extend to transaction alerts).
- Transaction above configurable threshold →
SourceEventwithproposal_type=decision. - Daily cash balance summary pushed to Brief.
-
As a CEO, I want Lumi to pull staffing alerts from Base.vn or 1Office, so that I know when someone is on leave during a critical delivery week.
- Connect Base.vn / 1Office (API-based, scoped to leave/absence).
- Leave scheduled within 7 days of a project deadline →
SourceEventwithproposal_type=commitment. - Rule: match leave dates against project deadlines in Lumi.
-
As a CEO, I want Lumi to ingest Microsoft 365 signals, so that teams using Outlook/Teams/Excel get the same signal quality as Google Workspace teams.
- Connect Microsoft 365 via Graph API (delegated or app-only, scoped to mail + calendar + Excel).
- Calendar: meetings marked “urgent” or with “decision” in subject →
SourceEvent. - Excel Online: monitored workbook changes →
SourceEvent.
General Framework
- As a developer adding a connector, I want a connector SDK/contract so new integrations take hours, not weeks.
- Every connector implements the
BaseConnectorinterface:connect(),poll(),disconnect(),health_check(),normalize(raw_event) → SourceEvent. - Connectors register themselves in
CONNECTOR_REGISTRYwith provider_slug, category, auth schema, and default rules. - Polling is Celery-beat-driven via a unified
poll_all_active_connectorstask — each connector gets its own sub-task.
- Every connector implements the
3. Scope Boundaries
- In:
- Phase 1 (launch): Zalo OA, MISA/Fast Accounting, Shopify/Haravan, Google Workspace.
- Phase 2 (post-launch): VietQR transaction alerts, Base.vn/1Office, Microsoft 365.
- Connector framework:
BaseConnectorabstract class, unified polling, auth management, health tracking. - All connectors feed through F33’s existing
SourceConnection→SourceRule→SignalInboxItempipeline. - Each connector ships with default rules that create signal inbox items (never auto-promote to ops).
- Connector UI lives in the existing
/settings/sourcespage (F33 surface). - OAuth flow handling (redirect URL, token storage, refresh).
- Out:
- Building the source system’s features inside Lumi (no Zalo chat client, no accounting UI, no spreadsheet editor).
- Bi-directional sync — Lumi reads from these systems; it never writes back (v1).
- Full data mirror/migration — Lumi ingests exceptions and summaries, not complete databases.
- Native mobile push for connector signals (F36 handles push; F44 just creates the signals).
- Connector marketplace/plugin system — connectors are first-party only, built into the product.
- WhatsApp as a conversational bot or in-app chat client. WhatsApp Business source ingestion is covered by F76 (planned spec) and the regional connector rollout in F45.
- Slack/Discord/Teams chat integration (F29 is the internal chat; external chat platforms are out of scope per wedge doctrine).
4. Data Model
4.1 Extensions to F33 Models
All connectors use the existing SourceConnection model (F33). New fields:
# SourceConnection additions
auth_expires_at = models.DateTimeField(null=True) # OAuth token expiry
last_polled_at = models.DateTimeField(null=True) # Last successful poll
poll_interval_minutes = models.IntegerField(default=5) # Configurable per-connection
4.2 Connector-Specific Configs
Each connector’s config_json stores its own auth/config shape:
# Zalo OA config
{"oa_id": "...", "access_token": "...", "refresh_token": "...", "app_id": "..."}
# MISA config
{"company_tax_code": "...", "api_key": "...", "api_secret": "..."}
# Shopify config
{"shop_domain": "xxx.myshopify.com", "access_token": "...", "scope": "read_orders"}
# Google Workspace config
{"service_account_email": "...", "private_key_id": "...", "domain": "company.com",
"monitored_folder_ids": ["...", "..."], "monitored_sheet_ids": ["...", "..."]}
# WhatsApp Business config (regional)
{"phone_number": "...", "market": "my|id|ph|sa", "app_secret": "..."}
4.3 Default Rules Per Connector
Shipped as data migrations (every connector installs sensible defaults):
| Connector | event_type | proposal_type | Default Threshold |
|---|---|---|---|
| Zalo OA | message_unanswered | risk | severity: 2h=medium, 6h=high, 24h=critical |
| Zalo OA | message_negative_sentiment | risk | triggers immediately |
| MISA | invoice_overdue | risk | 15 days overdue |
| MISA | invoice_severely_overdue | risk | 30 days overdue, severity=critical |
| Fast Accounting | invoice_overdue | risk | same as MISA |
| Shopify | order_unfulfilled | task | 48h unfulfilled |
| Shopify | refund_received | risk | triggers immediately |
| Haravan | order_unfulfilled | task | same as Shopify |
| Google Workspace | sheet_row_added | commitment | admin configures which columns = what |
| Google Workspace | doc_modified | decision | if doc title contains keywords |
| WhatsApp Business | whatsapp_unanswered | risk | age at ingest: 2h auto-route; severity tiers defined in F45/F76 |
| VietQR | large_transaction | decision | configurable threshold (default 50M VND) |
| Base.vn | leave_conflicts_with_deadline | commitment | within 7 days of project deadline |
4.4 No New Models
All connector state lives in SourceConnection.config_json, cursor_json, and last_error_text. The SourceEvent model handles all event shapes and is hash-partition-ready by organization: dedupe uniqueness includes organization, and org-leading indexes cover chronological, connection, status, and event-type cursor paths.
4.5 Connector Contracts — Detailed
Each launch connector has a defined contract covering auth type, normalized event schema, rate limits, and error handling.
4.5.1 Connector Contract Table
| Connector | Auth Type | Auth Fields | Poll Interval | Rate Limit | Retry Strategy |
|---|---|---|---|---|---|
| Zalo OA | OAuth2 | oa_id, access_token, refresh_token, app_id | 5 min | 100 req/min | Exponential backoff (60→120→240s) × 3, then degraded |
| MISA | API Key | company_tax_code, api_key, api_secret | 15 min | 30 req/min | Exponential backoff (60→120→240s) × 3, then degraded |
| Fast Accounting | API Key | company_tax_code, api_key, api_secret | 15 min | 60 req/min | Exponential backoff (60→120→240s) × 3, then degraded |
| Shopify | OAuth2 | shop_domain, access_token, scope | 10 min | 40 req/min per shop | Exponential backoff (60→120→240s) × 3, then degraded |
| Haravan | OAuth2 | shop_domain, access_token, refresh_token | 10 min | 40 req/min per shop | Exponential backoff (60→120→240s) × 3, then degraded |
| Google Workspace | Service Account | service_account_email, private_key_id, domain, monitored_folder_ids, monitored_sheet_ids | 15 min | 60 req/user/min | Exponential backoff (60→120→240s) × 3, then degraded |
4.5.2 Normalized Event Schema
All connector events use a common SourceEvent shape with connector-specific fields in raw_payload:
{
"id": "uuid",
"connection_id": "uuid",
"provider_slug": "zalo_oa|misa|fast_accounting|shopify|haravan|google_workspace",
"event_type": "string",
"external_event_id": "string",
"severity": "low|medium|high|critical",
"title": "string",
"body_text": "string",
"actor": "string|null",
"proposal_type": "risk|task|commitment|decision",
"raw_payload": {"...": "..."},
"captured_at": "datetime",
"created_at": "datetime"
}
Connector-specific event_type values:
| Connector | event_type | title template | body_text source |
|---|---|---|---|
| Zalo OA | message_unanswered | ”Zalo message unanswered ({hours}h)“ | Last message text (truncated 500 chars) |
| Zalo OA | message_negative_sentiment | ”Negative sentiment on Zalo” | Message text + sentiment label |
| MISA | invoice_overdue | ”Invoice {code} overdue ({days}d)“ | Invoice summary (amount, date, customer) |
| MISA | invoice_severely_overdue | ”Invoice {code} 30+ days overdue” | Invoice summary + escalation note |
| Fast Accounting | invoice_overdue | ”Invoice {code} overdue ({days}d)“ | Same as MISA |
| Shopify | order_unfulfilled | ”Order {name} unfulfilled ({hours}h)“ | Order summary (items, total, customer) |
| Shopify | refund_received | ”Refund issued for order {name}“ | Refund amount + reason |
| Haravan | order_unfulfilled | ”Order {code} unfulfilled ({hours}h)“ | Same as Shopify (Haravan field mapping) |
| Google Workspace | sheet_row_added | ”New row in {sheet_name}“ | Row data as joined text |
| Google Workspace | doc_modified | ”Doc modified: {doc_title}“ | Document excerpt (first 300 chars) |
4.5.3 Error Handling Per Connector
| Connector | 401/403 | 429 | 5xx | Network Error |
|---|---|---|---|---|
| Zalo OA | Token expired → attempt refresh → if fail, mark degraded | Backoff + retry ×3 | Retry on next poll cycle; mark degraded after 3 consecutive failures | Log error, retry next poll |
| MISA | Auth invalid → mark degraded immediately | Backoff + retry ×3 | Same as above | Same as above |
| Fast Accounting | Auth invalid → mark degraded immediately | Backoff + retry ×3 | Same as above | Same as above |
| Shopify | Token expired/revoked → mark degraded, notify admin to reconnect | Backoff + retry ×3 | Same as above | Same as above |
| Haravan | Token expired → attempt refresh → if fail, mark degraded | Backoff + retry ×3 | Same as above | Same as above |
| Google Workspace | Token expired → refresh via JWT grant → if fail, mark degraded | Backoff + retry ×3 | Same as above | Same as above |
5. API Contract
5.1 Existing Endpoints (Reused from F33)
All connector management uses the existing F33 CRUD endpoints. New endpoints are connector-action only:
| Method | Path | Role | Description |
|---|---|---|---|
| POST | /api/source-connections/<id>/connect/ | admin | Test OAuth flow or API key. Sets status=active on success. |
| DELETE | /api/source-connections/<id>/ | admin | Remove connection; revokes tokens and sets status=disabled. |
| POST | /api/source-connections/<id>/sync/ | admin | Trigger manual sync (bypasses poll interval). |
| GET | /api/source-connections/catalog/ | viewer | List available connector types with category, description, auth requirements. |
5.2 Connector Catalog Response
{
"connectors": [
{
"provider_slug": "zalo_oa",
"name": "Zalo Official Account",
"category": "support",
"description": "Monitor customer messages. Flags unanswered conversations.",
"auth_type": "oauth2",
"auth_fields": ["oa_id"],
"oauth_url": "https://oauth.zalo.me/v4/permission",
"phases": ["launch"]
},
...
]
}
5.3 Webhook Reception
F33 already handles webhook reception. New connector webhooks (Zalo OA callbacks, Shopify webhooks, Haravan webhooks) reuse the existing POST /api/source-connections/webhook/<secret>/ endpoint. Each connector registers its webhook URL with the source system via the connect() flow.
5.4 OAuth Flow
5.4.1 Redirect URL Pattern
All OAuth-based connectors (Zalo OA, Shopify, Haravan) follow the same redirect pattern:
POST /api/source-connections/<id>/connect/
→ Returns {"oauth_url": "https://provider.com/oauth/authorize?..."}
redirect_uri = "https://api.runlumi.app/api/source-connections/oauth/callback/"
The redirect_uri is fixed per environment — registered with each OAuth provider’s developer console during setup.
5.4.2 Authorization Flow
- Admin clicks “Connect” for an OAuth connector in the UI.
- UI calls
POST /api/source-connections/<id>/connect/which returns the provider’s OAuth URL. - Admin is redirected to provider’s consent page.
- Provider redirects back to
{redirect_uri}?code=AUTH_CODE&state=STATE. - The callback endpoint exchanges the code for tokens:
POST /oauth/token→{access_token, refresh_token, expires_in}
- Tokens are stored in
SourceConnection.config_json. SourceConnection.statusis set toactive.- Default rules are installed via data migration.
5.4.3 Token Refresh Mechanism
def refresh_oauth_token(connection: SourceConnection) -> bool:
"""
Attempts OAuth token refresh. Returns True if successful.
1. Read refresh_token from connection.config_json
2. POST to provider's /oauth/token endpoint with grant_type=refresh_token
3. If success: update config_json with new tokens, update auth_expires_at
4. If failure (invalid_grant): set status=degraded, last_error_text="token_expired"
5. If provider down: keep current tokens, retry next poll
"""
- Refresh is attempted automatically when:
- A poll request returns 401/403.
- The pre-emptive check shows
auth_expires_atis within 5 minutes.
- Successful refresh: updates
config_json.access_token,config_json.refresh_token(if rotated),auth_expires_at. - Failed refresh with
invalid_grant: connector transitions todegraded. Admin notified via UI badge. Admin must reconnect. - Failed refresh due to network error: retried on next poll cycle. Tokens preserved.
5.4.4 OAuth Error States
| Error | Behavior | User-visible message |
|---|---|---|
| User denies consent | connect() returns {"status": "error", "reason": "access_denied"} | sources.oauth_error_denied |
| Auth code expired | Callback returns error. Connection remains pending. | sources.oauth_error_code_expired |
Token refresh returns invalid_grant | refresh_oauth_token() returns False. Connector → degraded. | sources.oauth_error_expired |
| Token refresh fails (network) | Retried on next poll. Connector stays active. | n/a (silent retry) |
| Redirect URI mismatch | Provider returns error during auth. | sources.oauth_error_redirect_mismatch |
| State mismatch (CSRF) | Callback rejected. Logged for audit. | sources.oauth_error_state_mismatch |
6. UI / UX
6.1 /settings/sources Page Extensions
The existing F33 SourcesPage gets a connector catalog view:
┌─ Connected Sources ───────────────────────────────────┐
│ │
│ [+ Connect a source] │
│ │
│ ┌─ Zalo OA ─────── ● Active ────── [Disconnect] ──┐ │
│ │ Last polled: 2 min ago │ │
│ │ 23 messages today · 2 unanswered (>2h) │ │
│ │ [View events] [Configure rules] │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─ Shopify ─────── ● Active ────── [Disconnect] ──┐ │
│ │ Last polled: 4 min ago │ │
│ │ 47 orders today · 1 unfulfilled (>48h) │ │
│ │ [View events] [Configure rules] │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─ MISA ─────── ◐ Degraded ────── [Reconnect] ────┐ │
│ │ Error: API token expired │ │
│ │ Last synced: 2 hours ago │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────┘
6.2 Connection Flow
- Admin clicks ”[+ Connect a source]” → catalog opens.
- Select connector type → see auth requirements.
- For OAuth: redirected to provider’s OAuth page → back to Lumi callback → connection active.
- For API key: paste credentials → “Test connection” → active.
- Default rules installed automatically. Admin can customize thresholds in “Configure rules.”
6.3 Signal Appearance in Inbox
Connector signals in /intel/inbox show:
- Source badge: “[Zalo OA]” / “[Shopify]” / “[MISA]”
capture_channel:connectorseverityindicator from rule mapping- Link back to
SourceEventdetail
6.4 i18n
All existing sources namespace keys (F33) remain. New keys added for F44 connectors:
Connector labels (used in catalog + connection card):
| Key | en | vi |
|---|---|---|
sources.connector_zalo_oa | Zalo Official Account | Zalo Official Account |
sources.connector_misa | MISA | MISA |
sources.connector_fast_accounting | Fast Accounting | Fast Accounting |
sources.connector_shopify | Shopify | Shopify |
sources.connector_haravan | Haravan | Haravan |
sources.connector_google_workspace | Google Workspace | Google Workspace |
Connector descriptions (shown in catalog card):
| Key | en | vi |
|---|---|---|
sources.connector_desc_zalo_oa | Monitor customer messages. Flags unanswered conversations. | Theo dõi tin nhắn khách hàng. Cảnh báo hội thoại chưa trả lời. |
sources.connector_desc_misa | Track overdue invoices and cash flow. | Theo dõi hoá đơn quá hạn và dòng tiền. |
sources.connector_desc_fast_accounting | Track overdue invoices from Fast Accounting. | Theo dõi hoá đơn quá hạn từ Fast Accounting. |
sources.connector_desc_shopify | Monitor order fulfillment and refunds. | Theo dõi tình trạng đơn hàng và hoàn tiền. |
sources.connector_desc_haravan | Monitor Haravan order fulfillment. | Theo dõi tình trạng đơn hàng Haravan. |
sources.connector_desc_google_workspace | Monitor shared Drive folders and Sheets. | Theo dõi thư mục Drive và bảng tính được chia sẻ. |
Auth flow keys:
| Key | en | vi |
|---|---|---|
sources.oauth_redirecting | Redirecting to provider… | Đang chuyển hướng đến nhà cung cấp… |
sources.oauth_success | Connection authorized successfully | Kết nối đã được xác thực thành công |
sources.oauth_error_denied | Authorization denied by provider | Từ chối xác thực từ nhà cung cấp |
sources.oauth_error_code_expired | Authorization code expired — try again | Mã xác thực đã hết hạn — thử lại |
sources.oauth_error_expired | Connection expired — reconnect required | Kết nối đã hết hạn — cần kết nối lại |
sources.oauth_error_redirect_mismatch | Redirect URI mismatch — contact support | URI chuyển hướng không khớp — liên hệ hỗ trợ |
sources.oauth_error_state_mismatch | Security validation failed — try again | Xác thực bảo mật thất bại — thử lại |
Action keys:
| Key | en | vi |
|---|---|---|
sources.poll_now | Poll now | Đồng bộ ngay |
sources.last_polled | Last polled: {time} ago | Đồng bộ lần cuối: {time} trước |
sources.test_connection | Test connection | Kiểm tra kết nối |
sources.reconnect | Reconnect | Kết nối lại |
sources.connector_catalog_title | Connect a source | Kết nối nguồn |
sources.events_today | {count} events today | {count} sự kiện hôm nay |
sources.unanswered_count | {count} unanswered | {count} chưa trả lời |
sources.unfulfilled_count | {count} unfulfilled | {count} chưa xử lý |
Validation keys:
| Key | en | vi |
|---|---|---|
sources.connector_auth_required | {connector} requires {auth_type} authentication | {connector} yêu cầu xác thực {auth_type} |
sources.connector_reconnect_message | This connection needs to be reconnected | Kết nối này cần được kết nối lại |
sources.connector_error_token_expired | Token has expired — reconnect to continue | Token đã hết hạn — kết nối lại để tiếp tục |
sources.connector_error_rate_limited | Provider rate limit exceeded — try again later | Nhà cung cấp giới hạn tốc độ — thử lại sau |
sources.connector_error_provider_down | Provider is temporarily unavailable | Nhà cung cấp tạm thời không khả dụng |
Both en and vi locale files must be updated together. All 8 language dirs receive en placeholders for unsupported languages (existing convention).
7. Test Plan
Coverage note (2026-06-14): Connector tests live in
backend/tests/test_connectors.py. Real classes includeTestF44BaseConnectorContract,TestF44ConnectorRegistry,TestF44ConnectorCatalogEndpoint,TestF44ShopifyConnector,TestF44ZaloConnector,TestF44MisaConnector,TestF44FastAccountingConnector,TestF44HaravanConnector,TestF44GoogleWorkspaceConnector,TestF44RegistryIncludesAll,TestConnectorOAuthFlow,TestConnectorCRUD,TestCatalogMarketFilter. Rows below use original per-case naming; the file is authoritative.
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Zalo OA connector — OAuth flow completes, connection active, tokens stored | backend | tests/test_connectors.py |
| 2 | Zalo OA connector — unanswered messages create SourceEvents after 2h | backend | same class |
| 3 | Zalo OA connector — severity auto-scales (2h=medium, 6h=high, 24h=critical) | backend | same class |
| 4 | Zalo OA connector — 401 triggers token refresh; if fail → degraded | backend | same class |
| 5 | Zalo OA connector — connection status (active/degraded/disabled) visible in catalog | backend | tests/test_connectors.py |
| 6 | MISA connector — overdue invoices (15d) create risk signals | backend | tests/test_connectors.py |
| 7 | MISA connector — severely overdue invoices (30d) have severity=critical | backend | same class |
| 8 | MISA connector — 429 triggers exponential backoff (60→120→240s × 3) | backend | same class |
| 9 | Fast Accounting connector — overdue invoices create risk signals | backend | tests/test_connectors.py |
| 10 | Fast Accounting connector — 5xx × 3 consecutive → degraded | backend | same class |
| 11 | Shopify connector — OAuth flow (redirect → callback → token storage) | backend | tests/test_connectors.py |
| 12 | Shopify connector — unfulfilled orders 48h+ create task signals | backend | same class |
| 13 | Shopify connector — refunds/chargebacks create risk signals | backend | same class |
| 14 | Shopify connector — webhook registered on connect, receives events | backend | same class |
| 15 | Haravan connector — OAuth flow completes, connection active | backend | tests/test_connectors.py |
| 16 | Haravan connector — unfulfilled orders 48h+ create task signals | backend | same class |
| 17 | Haravan connector — duplicate webhook events deduped by (organization_id, connection_id, external_event_id) | backend | same class |
| 18 | Google Workspace connector — service account auth, Drive/Sheets read-only scoped | backend | tests/test_connectors.py |
| 19 | Google Workspace connector — new sheet rows create commitment signals | backend | same class |
| 20 | Google Workspace connector — doc modified creates decision signals | backend | same class |
| 21 | Google Workspace connector — token refresh fails → degraded, events preserved | backend | same class |
| 22 | Connector poll task runs on beat, respects per-connector poll intervals | backend | tests/test_connectors.py |
| 23 | Connector with expired token → degraded, SourceEvents preserved | backend | same class |
| 24 | Cross-tenant isolation — org A’s connector never sees org B’s data | backend | tests/test_multitenancy.py |
| 25 | Connector catalog returns correct info per role (admin sees all, member sees connected only) | backend | tests/test_connectors.py |
| 26 | Default rules install automatically on connection | backend | tests/test_connectors.py |
| 27 | Admin connects Zalo OA via UI, sees events in inbox | e2e | describe("API Integrations") |
| 28 | Admin connects Shopify, sees order event in inbox | e2e | same block |
| 29 | Connector permanently deauthorized → disabled, events preserved | backend | tests/test_connectors.py |
| 30 | Rate limit exceeded 10×/hour → degraded, rate_limited_frequent | backend | same class |
| 31 | Connector payload drift (missing fields) → degraded, raw_payload preserved | backend | same class |
| 32 | Rolling deploy: old tasks can create a source after the HMAC column lands without a NotNullViolation; new application-created rows still require HMAC | backend | tests/platform/test_rolling_deploy_db_default_guard.py + tests/test_rolling_deploy_db_default_guard.py |
8. Current Behavior
- F33 ships the generic source-ingestion spine:
SourceConnection→SourceRule→SourceEvent→SignalInboxItem. - Native vendor-specific connectors do exist under
backend/apps/connectors/, including Shopify, Zalo OA, MISA/Fast Accounting, Google Workspace, and the regionalwhatsapp_businessconnector. - OAuth flow handling exists for supported providers via the source-connection OAuth endpoints.
- Polling infrastructure exists for poll-based connectors, while webhook-first connectors such as WhatsApp use the generic webhook ingress path.
- Connector config and webhook secrets encrypt at the field boundary after the shared cutover; webhook URL lookup uses a digest index while preserving constant-time verification.
- All connector credential columns use one expand/contract rollout: migration
0005introduces cutover-aware config/webhook fields and backfills lookup hashes without rewriting values while old ECS tasks can still serve. The expand release keepsCONNECTOR_WEBHOOK_ENCRYPTION_ENABLED=false, reads plaintext or ciphertext, writes old-reader-compatible config/webhook plaintext plus the lookup hash, and repairs a hashless plaintext row written by an overlapping old task. After all old app/worker tasks drain, operators enable the flag, roll compatible tasks, and only then runcutover_webhook_secret_encryption;--restore-legacyrestores config and webhook values under the same row lock before an old-image rollback. Existing senders remain on URL-secret compatibility until the Connections UI reveals a replacement key and the operator explicitly enables HMAC. - Generic connector creation reveals its signing key only in the creation response. The web catalog intercepts that response with a non-dismissible, copyable handoff and does not continue provider setup until the operator acknowledges it; list/detail serialization never returns the key. WhatsApp instead collects and stores Meta’s app secret before creation and does not generate or reveal an unused generic key.
- Owners/admins prepare a missing or lost signing key through
POST /api/source-connections/{id}/rotate-webhook-signing-key/; the replacement is revealed only in that response and excluded from normal serialization. Recovery remains available after interrupted creation even when HMAC is already required, but active-key rotation requires explicit destructive confirmation before the API call because rotation immediately invalidates the sender’s configured key.POST /api/source-connections/{id}/enable-webhook-hmac/is a distinct second step for staged legacy senders so enforcement never precedes handoff. The OpenAPI create response explicitly declares the one-time key andwebhook_hmac_requiredstate. - New and explicitly activated generic webhooks require HMAC; staged legacy rows remain live until their owner completes the handoff. WhatsApp setup collects the Meta app secret and phone number before creating the source, because Meta’s signature is derived from that app secret rather than Lumi’s generated generic key. OAuth state is expiring, one-time, and bound to user/organization/provider; Shopify and Haravan dynamic hosts are restricted to
*.myshopify.comand*.myharavan.comrespectively. webhook_hmac_requiredkeeps a database-levelfalsedefault for old-task inserts during a rolling deployment while the current application model defaults new rows totrue; this preserves mixed-version availability without weakening new-connection enforcement.- The connector catalog exists at
GET /api/source-connections/catalog/.
9. Error States
| # | Scenario | User-visible | Internal | Log | Retry |
|---|---|---|---|---|---|
| 1 | OAuth token expires during poll | Connector badge: “Degraded — reconnect” | refresh_oauth_token() attempted; if fail → status=degraded | WARNING “oauth_token_expired” | Refresh retried on every poll attempt while degraded |
| 2 | Polling fails 3 consecutive times | Connector badge: “Degraded” with error text | status=degraded, last_error_text set | ERROR “poll_3x_failure” | Retry every 15 minutes automatically |
| 3 | Source returns 429 (rate limit) | Transient — no user-visible change | Exponential backoff + retry ×3 | WARNING “rate_limited” | Retry in 60-240s. Normal interval afterward |
| 4 | Source returns 5xx | Transient — no user-visible change | Log error. Skip poll cycle. | ERROR “provider_5xx” | Retry on next scheduled poll |
| 5 | Source returns empty result | No change | Update last_polled_at. No-op. | DEBUG “empty_poll” | n/a |
| 6 | Admin disconnects manually | Connector disappears from active list | status=disabled, tokens revoked | INFO “admin_disconnected” | n/a |
| 7 | Webhook replay (duplicate) | No change | Silently ignored — dedup key check | DEBUG “webhook_duplicate” | n/a |
| 8 | Connector permanently deauthorized | Connector: “Disabled — reconnect to continue” | status=disabled, events preserved | WARNING “permanently_deauthorized” | n/a — manual reconnect only |
| 9 | Network error during poll | Transient — no user-visible change | Log error, skip cycle | WARNING “poll_network_error” | Next scheduled poll |
| 10 | Connector payload format changed | Connector badge: “Degraded — unexpected data format” | status=degraded, raw payload preserved, Sentry alert | ERROR “payload_drift_detected” | Engineering must patch connector |
| 11 | Webhook registration fails | Connector operates in poll-only fallback | last_error_text set, retry queued | WARNING “webhook_registration_failed” | 3× retry at 1-hour intervals |
10. Diagrams & Docs Touched
- backend/DIAGRAMS.md: add
apps/connectors/app withBaseConnectorclass hierarchy and poll scheduler. - frontend/DIAGRAMS.md: update
/settings/sourcesroute tree with connector catalog and connection flow. - F33 — Connected Source Signals: update §11 audit to note that F44 extends F33 with native connectors.
- New:
backend/apps/connectors/— connector implementations. backend/apps/sources/— extended with catalog endpoint and polling.
11. Implementation Audit
Edge Cases Catalog (formerly §9, 2026-06-14; 9 items): auth-expires mid-poll (refresh then cursor resume or degraded), dedup via (organization_id, connection_id, external_event_id) unique constraint + SHA256 for systems without IDs, 429 exponential backoff 60/120/240s (Retry-After override), permanent deauthorization → disabled + reconnect flow, payload drift → degraded + Sentry + raw_payload preserved, empty-response no-op, webhook replay 5-min window + SHA256 dedup, concurrent OAuth pending_oauth 10-min flag, API version migration config_json.api_version. Full catalog in git history.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | BaseConnector abstract class (connect, poll, disconnect, health_check, normalize) | ✅ match | backend/apps/connectors/base.py |
| 2 | CONNECTOR_REGISTRY (provider_slug → connector class mapping) | ✅ match | backend/apps/connectors/registry.py |
| 3 | poll_active_connectors Celery beat task | ✅ match | backend/apps/sources/tasks.py (~line 48) |
| 4 | 6 Phase 1 connectors (Zalo OA, Shopify, Haravan, MISA, Fast Accounting, Google Workspace) | ✅ match | backend/apps/connectors/ |
| 4b | Regional WhatsApp Business connector shipped through the same framework | ✅ match | backend/apps/connectors/whatsapp.py, F76 (planned spec) |
| 5 | OAuth callback + token refresh service | ✅ match | oauth_initiate() / oauth_callback() in backend/apps/sources/views.py (~lines 278–323); check_token_expired() in backend/apps/sources/connectors.py (~line 283); no standalone oauth.py file exists |
| 6 | Catalog endpoint GET /api/source-connections/catalog/ | ✅ match | backend/apps/sources/views.py |
| 7 | SourceConnection model fields for sync state | ✅ match | backend/apps/sources/models.py (~lines 43–45): last_synced_at (DateTimeField), last_error_text (TextField), cursor_json (JSONField); poll_interval_minutes is a class attribute on BaseConnector subclasses, not a DB column; auth_expires_at and last_polled_at from the original spec were not added as DB fields |
| 8 | /settings/sources frontend — connector catalog, OAuth redirect, health badges | ✅ match | frontend/src/pages/Sources/ |
| 9 | (organization_id, connection_id, external_event_id) unique constraint on SourceEvent | ✅ match | SourceEvent.Meta.unique_together; org hash key added 2026-07-13 |
| 10 | Phase 2 connectors (VietQR, Base.vn, Microsoft 365) | ⚠️ gap | Deferred post-v1 (P5 in build plan) |
| 11 | rate_limit_hits counter in SourceConnection.config_json | ⚠️ gap | Explicitly deferred post-v1 — exponential backoff sufficient |
12. Open Questions / Decisions
- Polling vs. webhook for each connector: Zalo OA currently has no outbound webhook for message status (only for message receipt). Shopify has webhooks but they require a public HTTPS endpoint. Decision: implement both modes. Use webhooks when the source system supports them (lower latency, less API load). Fall back to polling when the source system only supports polling (MISA, Google Sheets change detection).
- VietQR vs. bank-specific APIs: Sepay handles VietQR payment receipt (already integrated in F24). Do we also integrate individual bank APIs (Vietcombank, Techcombank, MB Bank, etc.)? Decision: Sepay first (it aggregates multiple banks). Individual bank APIs deferred until a paying customer needs a specific bank.
- Connector error vs. source system outage: How long should Lumi wait before degrading a connector when the source returns 5xx? Decision: 3 consecutive failures over 15+ minutes = degraded. Less than that = transient, silently retried.
- Data retention for SourceEvents: Should raw connector payloads be pruned after N days? Decision: keep indefinitely for now (audit trail). Storage is cheap; the event is mostly metadata + summary, not full source system replication.
13. Bug / Change Log
2026-07-15 — Preserve HMAC rollout safety across old ECS tasks
Type: Deployment safety
- Kept
db_default=Falseonwebhook_hmac_requiredthrough both migration state transitions so an old task that omits the new column cannot raiseNotNullViolationduring rolling deployment. - Retained
default=Truein current application state, so newly created generic connectors still fail closed unless they are explicitly staged as legacy compatibility rows. - Added the field to the permanent rolling-deploy regression ratchet.
2026-07-14 — Close connector credential, webhook, OAuth, and SSRF findings
Type: Security hardening
- Required a valid production Fernet key delivered through native ECS Secrets Manager injection.
- Added encrypted dedicated webhook values and a deterministic lookup digest through a mixed-version-safe expand migration plus an explicit, reversible post-stabilization cutover.
- Added an owner/admin-only two-step legacy rollout: reveal a rotated key while URL-secret compatibility remains live, then enable fail-closed HMAC only after the operator confirms sender configuration.
- Added the missing OAuth-callback response and blocking web copy handoff so the one-response signing key is usable before HMAC enforcement.
- Bound OAuth state to the initiating principal and provider with one-time cache consumption.
- Rejected unsafe Shopify/Haravan dynamic hostnames before outbound calls.
- Corrected Haravan’s provider boundary to its actual
*.myharavan.commerchant domain and locked cutover row loading against concurrent signing-key rotation.
2026-07-13 — Source-event cursor and org-hash readiness
GET /api/source-events/ keeps page-number pagination as the compatibility default and
adds pagination=cursor with fixed UUIDv7 -id traversal. Dedupe uniqueness now includes
the future organization hash key, and filter-specific composite indexes remove scans on
high-volume connector ledgers. Invalid modes/cursors remain 400 and every filter remains
tenant-scoped.
2026-07-02 — Verified §5, §6, §10 are free of stale connectors/tasks.py and sources/oauth.py references (batch 3)
Type: Documentation
Summary:
- Grepped §5 (API Contract), §6 (UI/UX), and §10 (Diagrams & Docs Touched) for
connectors/tasks.pyandsources/oauth.py— none found. - The stale paths were already corrected in §11 rows 3 and 5 (see entry directly below).
- No additional changes required.
2026-07-02 — §5 and §11 corrected: task name, OAuth location, model fields, API actions (batch 12)
Type: Documentation Truth Fix
Summary:
- §5 API (Rows 2–3): Replaced non-existent
POST /api/source-connections/<id>/disconnect/andPOST /api/source-connections/<id>/poll/with the actual actions registered onSourceConnectionViewSet:DELETE /api/source-connections/<id>/for disconnect andPOST /api/source-connections/<id>/sync/for manual sync (verified againstbackend/apps/sources/views.pyandbackend/apps/sources/urls.py). - §11 Row 3: Corrected Celery task from
poll_all_active_connectorsatbackend/apps/connectors/tasks.py(non-existent) topoll_active_connectorsatbackend/apps/sources/tasks.py(~line 48) (verified by grep). - §11 Row 5: Replaced non-existent
backend/apps/sources/oauth.pywith the actual locations:oauth_initiate()/oauth_callback()inbackend/apps/sources/views.py(~lines 278–323) andcheck_token_expired()inbackend/apps/sources/connectors.py(~line 283) (verified by grep). - §11 Row 7: Replaced stale field list
auth_expires_at, last_polled_at, poll_interval_minutes(none are DB columns onSourceConnection) with the actual fields:last_synced_at,last_error_text,cursor_json(~lines 43–45 inbackend/apps/sources/models.py); noted thatpoll_interval_minutesis aBaseConnectorclass attribute, not a DB field (verified by grep).
2026-07-02 — Spec drift fix: checked shipped ACs in §2
Type: Documentation
Summary:
- 29 of 38
[ ]ACs in §2 flipped to[x]— confirmed shipped against §11 audit rows 1–9. - Phase 1 connector ACs (Zalo OA ×7, MISA ×4, Fast Accounting ×3, Shopify ×4, Haravan ×3, Google Workspace ×5) and General Framework ACs (×3) all confirmed by §11 rows 1–9 (all ✅).
- Phase 2 ACs (VietQR, Base.vn/1Office, Microsoft 365 — 9 total) remain
[ ]per §11 rows 10–11 (⚠️ gap, deferred post-v1). - No code changes.
2026-06-22 — Manual sync now runs in-request (no broker dependency)
Type: Implementation Change (no API contract change)
Summary:
POST /api/source-connections/{id}/sync/previously enqueued a Celery task viasync_single_connection.delay(...). With no Celery worker deployed in the ECS topology (deploy/DIAGRAMS.md→ “Worker Task … NOT DEPLOYED”) and no reachable broker, that path raisedkombu.exceptions.OperationalErrorand returned 500 to the user — the LUMI-BE-7 class from the 2026-06-16 chat-empty-replies postmortem.apps/sources/views.py:SourceConnectionViewSet.syncnow invokessync_single_connection(str(conn.id))synchronously in-request. Response shape is unchanged (202 Acceptedwith{"status": "queued"}); only the execution mechanism moved off the broker.- Regression test
backend/tests/test_sources.py::TestConnectorSyncAsync::test_sync_does_not_500_with_broker_downpins the contract under the production topology (eager OFF + dead broker). - Failure class sealed by static gate
scripts/check-no-delay-in-views.py(+ self-test, wired intomake verify): any future.delay()/.apply_async()in abackend/apps/**/views.pyfile fails the build with the postmortem named in the remediation message.
Reason:
- Production has no worker — the previous code 500’d for every user who
clicked “Sync now”. The scheduled fan-out path in
tasks.pystill uses.delay(), which is correct (background context); only the user-facing endpoint moved inline.
Impact:
- Users who click “Sync now” now get the actual sync result in-request
(instead of an immediate 500). Connector poll has its own per-connector
HTTP timeouts (see
apps/connectors/*), so a slow source bounds the request, not the broker.
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /sources -> /settings/sources and /inbox -> /intel/inbox (app-wide IA restructure; API paths unchanged).
2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)
Type: Documentation Change
Summary:
- §9 Edge Cases (9 items, 9.1–9.10 with gap at 9.7) absorbed into §11 Implementation Audit note.
- §12 Implementation Audit converted from Priority/Phase tables + narrative to canonical 4-column table (11 rows: 9 ✅, 2 ⚠️ gaps for Phase 2 connectors and rate_limit_hits counter).
- Sections renumbered: §10 ES → §9, §11 Diags → §10, §12 Audit → §11, §13 OQ → §12, §14 BL → §13.
Reason:
- Align with TEMPLATE.md canonical section order per SP fleet restructuring pass.
Impact:
- Spec authors and future implementation agents.
2026-06-13 — Spec refinement: detailed contracts, OAuth flow, edge cases, i18n
Type: Documentation Change
Summary:
- Added detailed connector contracts (§4.5) with auth types, normalized event schemas, rate limits, and per-connector error handling for all 6 launch connectors.
- Added OAuth flow documentation (§5.4): redirect URL pattern, authorization flow, token refresh mechanism, and error states.
- Refined Phase 1 acceptance criteria (§2) with full Given/When/Then structure per connector.
- Expanded test plan (§7) from 12 to 31 test cases, covering all connectors, edge cases, and error states.
- Added numbered Edge Cases (§9): auth expires mid-poll, duplicate events (dedup key strategy), rate limit exceeded (backoff policy), permanent deauthorization, payload drift, empty response, webhook registration failure.
- Replaced error state table with detailed 11-row matrix (§10) linking scenario → user-visible → internal → log → retry.
- Expanded i18n keys (§6.4) from brief list to full tables: 6 connector labels, 6 descriptions, 7 auth flow keys, 8 action keys, 5 validation keys with en/vi translations.
- Expanded Implementation Audit (§12) with per-priority tables, file paths, design constraints, and rework items.
- Updated spec status from
drafttoagreed.
Reason:
- Spec needed refinement per the implementation audit gap analysis. Connector contracts, OAuth specifics, and error handling were previously underspecified, making implementation ambiguous.
Impact:
- Spec authors and future implementation agents.
Tests:
- Spec-only change — no code tests affected.
2026-06-13 — Added edge cases: webhook replay attack, concurrent OAuth flows, connector migration
Type: Documentation Change
Summary:
- Added §9.8 (Webhook Replay Attack): replay window, stale timestamp rejection, dedup fallback.
- Added §9.9 (Concurrent OAuth Flows): pending_oauth flag, 10-min expiry, state param.
- Added §9.10 (Connector Migration): api_version tracking, backwards-compatible migration window, degraded behavior on unknown version.
Reason:
- Spec needed hardening from F44 audit review. These three edge cases were not captured in the initial spec refinement.
Impact:
- Spec authors and future implementation agents.
Tests:
- Spec-only change — no code tests affected.