Tickets Architecture¶
Canonical reference for how missed-call tickets are created, deduplicated, and surfaced in the editor. Read this before touching ticketsFromCallLogsScheduler, callLogsTicketQuery, the tickets / ticket_call_events tables, or the tickets page in the editor.
See also: Subscribe to Tickets (Daily Missed-Call Alerts) for the operator notification side.
TL;DR¶
- The call-logs-driven scheduler is the canonical pipeline for missed-call ticket creation across all orgs.
- Reads from
asterisk_cdrusing the sameANSWERED+billsec>0-preferring dedup the/api/v1/callsUI uses, so the operator's call-log and ticket views never disagree. - Settle window operates at session level (max
end_timeacross all rows of a linkedid), so multi-retry calls never produce false-positive tickets. - Enabled org-wide via
TICKETS_FROM_CALLLOGS_ENABLED_ORG_IDS='*'— new tenants auto-enrolled. - Manual ticket close only; no auto-close magic.
Data model¶
tickets table¶
| Column | Purpose |
|---|---|
id | UUID PK |
org_id | Tenant scope (every query is filtered by this) |
caller_number | Last-10-digit canonical form (normalised via Ticket.normalisePhone) |
caller_name | Snapshot at create time (from users.full_name if the caller is a known user) |
source | missed_call / queue_timeout / bot_dropped / manual |
priority | normal / high / urgent (≥1/≥2/≥3 missed_count) |
status | open / in_progress / closed / archived |
missed_count | Incremented on every re-call from the same number while ticket is open |
last_call_id | Asterisk linkedid (or uniqueid) of the most recent call that touched this ticket |
last_call_at, closed_at, archived_at | Timestamps for the state transitions + sort/sweep keys |
notes | JSON blob with category + legacy form fields (for manual tickets) |
Dedup invariant: at most one open ticket per (org_id, caller_number). Repeat calls increment missed_count and re-evaluate priority. Closed tickets are NOT reopened — a fresh call after close starts a brand-new ticket.
ticket_call_events table¶
Append-only timeline of every call attempt recorded against a ticket — the unique fingerprint of the new pipeline (legacy classifier never wrote here). Populated by the scheduler.
| Column | Purpose |
|---|---|
ticket_id | FK to tickets, ON DELETE CASCADE |
org_id | Denormalised from parent for cross-org safety + faster scans |
linkedid | Asterisk linkedid (call session) |
occurred_at | Time the call leg ended (calldate + duration on the CDR row) |
kind | missed / bot_dropped / outbound_attempt (future) |
meta | JSON snapshot: duration, billsec, disposition, lastapp, dstchannel |
UNIQUE (ticket_id, linkedid) is the idempotency guard. The scheduler's window overlaps consecutive polls; re-emitting the same event on the overlap is a silent no-op.
Pipeline (call-logs-driven scheduler)¶
Poll cycle every 60 seconds. Implemented in api/src/jobs/ticketsFromCallLogsScheduler.js.
1. parseEnabledOrgs(process.env.TICKETS_FROM_CALLLOGS_ENABLED_ORG_IDS)
→ Set of org UUIDs OR the wildcard token '*'
→ empty set = scheduler idle (no-op)
2. buildMissedCallsQuery({ orgIds, windowSecs=300, settleSecs=60 })
→ returns the SQL contract below
3. sequelize.query(sql, replacements) → one row per linkedid
4. For each missed row:
decideSourceAndKind(row) → { source: 'queue_timeout'|'missed_call', kind: 'missed' }
Ticket.upsertFromCdr(...) → find-or-increment per (org, caller)
TicketCallEvent.recordSafe({ ticket_id, linkedid, occurred_at, kind, meta })
→ duplicate-key on (ticket_id, linkedid) absorbed silently
5. One ticketStream.broadcast per org per tick → editor SSE refresh
SQL contract — session-level settle¶
WITH ranked AS (
SELECT
c.linkedid, c.uniqueid, c.accountcode AS org_id, c.src, c.dst,
c.dstchannel, c.lastapp, c.duration, c.billsec, c.disposition, c.calldate,
DATE_ADD(c.calldate, INTERVAL c.duration SECOND) AS end_time,
-- LATEST end_time across the whole session (all rows sharing this linkedid).
MAX(DATE_ADD(c.calldate, INTERVAL c.duration SECOND))
OVER (PARTITION BY c.linkedid) AS session_end_time,
ROW_NUMBER() OVER (
PARTITION BY c.linkedid
ORDER BY
CASE WHEN c.disposition='ANSWERED' AND c.billsec>0 THEN 1 ELSE 0 END DESC,
c.duration DESC, c.id DESC
) AS rk
FROM asterisk_cdr c
WHERE c.channel NOT LIKE 'Local/%'
AND c.dcontext LIKE '%\_incoming' ESCAPE '\\'
AND <org filter — IN(orgIds...) OR accountcode IS NOT NULL for wildcard>
AND DATE_ADD(c.calldate, INTERVAL c.duration SECOND)
>= DATE_SUB(NOW(), INTERVAL <prefilterSecs> SECOND)
)
SELECT * FROM ranked
WHERE rk = 1
AND session_end_time
BETWEEN DATE_SUB(NOW(), INTERVAL <windowSecs> SECOND)
AND DATE_SUB(NOW(), INTERVAL <settleSecs> SECOND)
AND NOT (
disposition='ANSWERED' AND billsec>0
AND (dstchannel REGEXP '^PJSIP/[a-zA-Z0-9_-]+-'
OR dstchannel REGEXP '^Local/qm[a-f0-9]{32}@')
)
ORDER BY session_end_time ASC
Three things this query gets right that the legacy classifier did not:
- Session-level settle. Multi-retry queue calls emit one CDR row per retry attempt (
NO ANSWERthenANSWEREDseconds later). Settling onsession_end_timeensures every retry row is in the DB before we pick a representative — so the dedup correctly picks the ANSWERED row and no false-positive ticket is ever created. - Dedup matches the call-logs UI.
ROW_NUMBER() PARTITION BY linkedid ORDER BY (ANSWERED+billsec>0) DESC— same predicate the/api/v1/callsendpoint applies (test S2 inapi/tests/server-routes.test.jsenforces this). The tickets table and call-logs view always agree on what was missed. - Bridged-answer exclusion. The outer
NOT (disposition='ANSWERED' AND billsec>0 AND <real bridge regex>)filter ignores any row that successfully bridged to a real PJSIP endpoint OR through the qm-helper context — those are completed calls, not misses.
Wildcard flag — *¶
TICKETS_FROM_CALLLOGS_ENABLED_ORG_IDS='*' means "all orgs go through this scheduler". The SQL drops the accountcode IN (...) clause and replaces it with accountcode IS NOT NULL AND <> ''. The legacy classifier gate in pollCdr honours the wildcard via isOrgEnabled(orgId, set) and short-circuits to skip-for-everyone.
Operational implication: new tenants created via the API are auto-enrolled. No env var update needed. No deploy needed. The next inbound call from the new org's DID is picked up by the scheduler within 60s.
To narrow the rollout (e.g. for an experiment), set the env to specific UUIDs:
Anything not in the list falls back to the legacy classifier (which still exists, just unused under wildcard).
What the scheduler does NOT do¶
- No auto-close. Once a ticket is open, only operator action (or the lazy archive sweep) changes its status. The legacy classifier had a cross-batch UPDATE that closed bogus tickets when a later ANSWERED row arrived; the new scheduler prevents the bogus ticket from being created in the first place (via session-level settle), so the auto-close isn't needed.
- No
bot_droppeddetection (yet) — see Issue #215. The legacy classifier createdbot_droppedtickets when an AI agent answered and the caller dropped under 8s. The new scheduler needs an AI-bridge column added to the call-logs SQL to surface this — deferred until any org with AI agents (GrandEstancia) starts generating live bot-handled traffic. - No outbound-attempt tracking (yet).
ticket_call_events.kindenum has anoutbound_attemptslot reserved for future "hospital called back the missed customer" logging. Wiring deferred.
Editor surfaces¶
The tickets page at /dashboard/<orgId>/tickets reads from these endpoints:
| Endpoint | Returns |
|---|---|
GET /api/v1/tickets | Paginated list. Order: actionable first (open+in_progress), then closed, then archived — within each bucket newest first. Payload includes status_counts: { open, in_progress, closed } for the header strip. |
GET /api/v1/tickets/:id/events | Append-only timeline for one ticket (max 200, newest first) — drives the expandable "Call timeline" panel in the Sheet drawer. |
POST /api/v1/tickets | Manual ticket creation (operator-typed; rare). |
PATCH /api/v1/tickets/:id | Status / priority / assignee / notes / tags. Closing stamps closed_at; the lazy sweep moves it to archived 24h later. |
GET /api/v1/tickets/stream | SSE — emits a refresh event whenever the scheduler touches the org's tickets. |
UI features wired off this: - Counts header strip ("Open: N · Closed: M") next to the Refresh button — excludes archived. - Sortable list with open tickets pinned to the top regardless of recency. - Closed time column — shows closed_at formatted, or em-dash for open rows. - Call timeline panel — fetched on Sheet drawer open; shows [Missed] / [Bot Dropped] / [Outbound] badge + timestamp + duration per attempt. - Missed-count fallback — auto-generated tickets render N missed call(s) in the Summary column when summary is empty. - Drawer "Missed attempts" line — sources from selectedEvents.length (the call-timeline count) when events are loaded; falls back to tickets.missed_count only for legacy tickets that pre-date the call-logs scheduler and have no events. This guarantees the headline number matches the timeline below it. Implemented in editor/app/dashboard/[orgId]/tickets/page.tsx after PR #236 / 2026-05-18.
Sidebar "Tickets" badge — MariaDB SSE, not Firestore¶
The red count next to the "Tickets" menu item in editor/components/layout/Sidebar.tsx reads subscribeToOpenTicketCount(orgId, …) from @/lib/tickets/api — the same SSE-driven hook the org overview "Open Tickets" card uses. Until PR #240 / 2026-05-18 the badge instead held a Firestore onSnapshot listener on astrapbx/<orgId>/tickets where status=='open'; that diverged from the canonical MariaDB store under TICKETS_FROM_CALLLOGS_ENABLED_ORG_IDS='*' (the API still dual-writes to Firestore via the events.astradial.com proxy, and the Firestore lifecycle isn't kept in lockstep with MariaDB). After the cutover, the badge, overview card, list, and drawer all read the same store and always agree.
The events.astradial.com → Firestore POST in api/src/server.js:~7670 still fires for every inbound CDR. Removing it is a separable cutover (any external consumers of the Firestore tickets collection need to migrate first); the badge migration just stopped reading the Firestore side.
Legacy classifier (still present, gated)¶
api/src/services/ticketClassifier.js and the per-row classifyAndUpsertTicket call inside pollCdr are still in the codebase for orgs explicitly excluded from the wildcard (currently: none). The classifier:
- Reads per-row from
asterisk_cdr. - Applies a disposition override for IVR/queue-abandoned ANSWERED rows (
cdrDispositionOverride.js). - Has cross-batch auto-close logic for the case where a NO_ANSWER row creates a ticket before the ANSWERED row arrives.
It will be removed once the new scheduler has been running stably long enough that the legacy code is genuinely dead weight. Don't add new features there.
Gotchas¶
- Wildcard interaction with the legacy gate.
isOrgEnabled(orgId, set)returns true if the set contains'*'OR the specific UUID. Both the SQL builder and thepollCdrgate consult this helper — don't.has()the set directly or you'll miss the wildcard. ticket_call_events.linkedidis the natural key, notuniqueid. Multi-retry sessions share a linkedid; uniqueid differs per retry. The UNIQUE index is on(ticket_id, linkedid).- Settle / window defaults are env-tunable.
TICKETS_FROM_CALLLOGS_POLL_MS,TICKETS_FROM_CALLLOGS_WINDOW_SECS,TICKETS_FROM_CALLLOGS_SETTLE_SECS— defaults60_000/300/60. Operator tolerance for ticket latency is 60-90s past call end; don't shrink settle below ~30s without verifying multi-retry calls don't regress. - No backfill. The scheduler only sees sessions whose
session_end_timeis inside the current window. A session that ended >5 minutes ago when the scheduler is OFF (or the window misses it) will NOT be picked up on a later restart. For now this is acceptable — the daily WhatsApp alert scheduler reads fromticketsand doesn't depend on backfill. Ticket.upsertFromCdrMUST be called withlinkedidfrom the scheduler. The 300s scan window vs 60s poll interval means every CDR row appears in ~4 consecutive polls before itssession_end_timeslides out. Without per-linkedid idempotency on the increment side,missed_countinflates ~4× per actual call. Fixed PR #240 / 2026-05-18:upsertFromCdraccepts alinkedidarg and, when an existing ticket is found AND aticket_call_eventsrow already exists for(ticket_id, linkedid), the increment is a no-op (onlylast_call_atis nudged forward). Scheduler passesr.linkedid || r.uniqueid— same keyTicketCallEvent.recordSafeuses, so the two stay in lockstep on overlap. Legacy classifier and manual-ticket callers don't passlinkedidand retain the old unconditional-increment behaviour (the legacy classifier useslastCdrIdwatermarking, not a window, so it doesn't have the overlap shape). Reproduced before the fix: 2 actual CDR rows, 2 events,missed_count=8(caller9677949475, Thangavelu Hospital). One-shot backfill SQL ran 2026-05-18 to repair ~29 inflated open tickets and recompute their priorities; legacy tickets with zero events (predate the events table) were intentionally left untouched. Six unit tests cover the branches:api/tests/ticket-upsert-idempotency.test.js.
Where to read more¶
api/src/services/callLogsTicketQuery.js— SQL builder + decision helpers + wildcard logicapi/src/jobs/ticketsFromCallLogsScheduler.js— poll loop + per-row processing + statsapi/src/models/Ticket.js—upsertFromCdr(the find-or-increment with row-locking) + sweepArchiveapi/src/models/TicketCallEvent.js—recordSafe(the idempotent insert)api/tests/call-logs-ticket-query.test.js→ CL1-CL22 (SQL shape, dedup, wildcard, session-settle)api/tests/tickets-from-call-logs-scheduler.test.js→ S1-S8 (poll loop behaviour)api/tests/ticket-upsert-idempotency.test.js→ U1-U6 (per-linkedid guard branches; counter no-op on dedup; legacy no-linkedid back-compat)- Subscribe to Tickets (Daily Missed-Call Alerts) — operator-side daily summary