Skip to content

fix(history): keep unanswered legs so grouped calls stay complete - #356

Open
tommaso-ascani wants to merge 6 commits into
ns8from
feat_group_calls
Open

tommaso-ascani wants to merge 6 commits into
ns8from
feat_group_calls

Conversation

@tommaso-ascani

Copy link
Copy Markdown

Reference NethServer/dev#8105

The history queries dropped every NO ANSWER row that shared its uniqueid with an ANSWERED one, and kept at most one unanswered row per uniqueid+linkedid.

That hid the legs a call actually rang. A ring group dials all of its members from the same channel, so every leg shares one uniqueid and all but one were discarded here — which is why a ring-group call could never be expanded in the call history, while a queue call (whose attempts get their own uniqueid) could.

With call grouping those legs are the call's interactions: the middleware collapses them by linkedid into a single expandable row, so they have to reach it intact. Removed from both the personal and the switchboard query.

The extra rows are not shown as-is: the middleware groups them, and prunes the queue's own per-member bookkeeping legs (nethesis/nethcti-middleware#70).

Test Case

  1. Create a ring group with at least two reachable members.
  2. Call the group and answer from one member.
  3. Open the CTI call history: the call is one row (destination = who answered) and expanding it lists every member that rang.
  4. Repeat without answering: the row shows the group name, expanding it lists the members.
  5. Check a queue call and a direct call are unchanged.

The history queries dropped every "NO ANSWER" row that shared its uniqueid
with an ANSWERED one, and kept at most one unanswered row per
uniqueid+linkedid. That hid the legs a call actually rang: a ring group
dials all its members from the SAME channel, so all its legs share one
uniqueid and all but one were discarded here.

With call grouping those legs are the call's interactions — the middleware
collapses them by linkedid into a single expandable row — so they must
reach it intact. Removed from both the personal and the switchboard query.

Reference NethServer/dev#8105

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tommaso-ascani tommaso-ascani self-assigned this Aug 24, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf39022d94

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@tommaso-ascani

Copy link
Copy Markdown
Author

Code review

Found 2 issues:

  1. Cross-repository impact not evaluated. These two queries back /historycall/interval and /histcallswitch/interval, which are consumed by more than the call-history page: nethlink fetches the same endpoint for its last-calls panel with a fixed limit=15 (src/shared/useNethVoiceAPI.ts), and so do the CTI drawers. Now that every leg is returned, that window fills with legs of the same call, so the desktop client shows the same call repeated and fewer distinct calls. Only the CTI history goes through the middleware that groups them by linkedid; every other consumer receives the raw legs. Per the workspace AGENTS.md ("Always evaluate cross-repository impact" / "When changing APIs: verify nethcti-middleware, verify nethcti-server, verify frontend/client compatibility"), the client side needs a decision before this merges.

'(cnum IN (?) OR dst IN (?)) AND ' +
'(calldate>=? AND calldate<=?) AND ' +
'(cnum LIKE ? OR clid LIKE ? OR dst LIKE ? OR cnam LIKE ? OR dst_cnam LIKE ? OR ccompany LIKE ? OR dst_ccompany LIKE ?)' +
// Unanswered legs are NOT filtered out any more. These two conditions used
// to drop every "NO ANSWER" row that shared its uniqueid with an ANSWERED
// one, and to keep at most one unanswered row per uniqueid+linkedid. A ring
// group dials all its members from the SAME channel, so all its legs share
// one uniqueid and all but one were discarded here. With call grouping those
// legs are the call's interactions: the middleware collapses them by
// linkedid into a single expandable row, so they must reach it intact.
' AND NOT (lastapp = "Stasis" AND lastdata = "satellite")',
data.endpoints, data.endpoints,
data.from, data.to,

  1. Only the "all directions" branch was changed, so the result now depends on the direction filter. The in, lost and internal branches still drop unanswered legs whose call was answered (linkedid NOT IN (SELECT uniqueid ... disposition = "ANSWERED" ...)). The same call is therefore expandable with no direction filter and not expandable with "Incoming", and a call answered elsewhere disappears entirely from that filter instead of being grouped.

'(cnum LIKE ? OR clid LIKE ? OR dst LIKE ? OR dst_cnam LIKE ? OR dst_ccompany LIKE ?)' +
'AND (disposition NOT IN ("NO ANSWER","BUSY","FAILED")' +
'OR (disposition IN ("NO ANSWER","BUSY","FAILED")' +
'AND linkedid NOT IN (SELECT uniqueid FROM cdr AS b WHERE disposition = "ANSWERED" AND b.uniqueid = cdr.linkedid)))' +
' AND NOT (lastapp = "Stasis" AND lastdata = "satellite")',
data.endpoints, data.endpoints,

'(calldate>=? AND calldate<=?) AND ' +
'(cnum LIKE ? OR clid LIKE ? OR dst LIKE ? OR cnam LIKE ? OR ccompany LIKE ? OR dst_cnam LIKE ? OR dst_ccompany LIKE ?) ' +
'AND (disposition NOT IN ("NO ANSWER","BUSY","FAILED") OR (disposition IN ("NO ANSWER","BUSY","FAILED") AND linkedid NOT IN (SELECT uniqueid FROM cdr AS b WHERE disposition = "ANSWERED" AND b.uniqueid = cdr.linkedid)))' +
' AND NOT (lastapp = "Stasis" AND lastdata = "satellite")',
data.trunks, data.trunks,

@tommaso-ascani

Copy link
Copy Markdown
Author

Correction to the review above

I measured issue 1 instead of reasoning about it, and it does not hold as written. Row counts over 60 days on the test PBX, before and after this change:

endpoint before after
/historycall/interval/user/201 (what NethLink calls) 45 46
/historycall/interval/user/202 29 32
/historycall/interval/user/203 36 36
/histcallswitch/interval 120 163

The two conditions only ever filtered NO ANSWER rows, and a personal history is mostly the user's own answered legs (24 of 28 rows in the sample). So NethLink sees between zero and three extra rows over two months, not a window full of duplicates.

The repetition its list already shows is pre-existing and unrelated: extension 201 gets 46 rows for 26 distinct calls now, and got 45 rows for the same 26 calls before. Those are multiple ANSWERED legs of one call, which neither condition ever touched.

Issue 2 (the direction filters still dropping legs, so the same call is expandable with no filter and not with "Incoming") stands as written.

tommaso-ascani and others added 2 commits September 7, 2026 10:37
Dropping the leg-deduplication conditions outright changed the response for
every consumer of these two endpoints, not only the call history: NethLink
lists the same rows in its last-calls panel, and so do the CTI drawers and
the mobile app. On this test PBX those callers gained between zero and three
rows over two months, all of them queue bookkeeping legs of a single call
(three near-identical "401 no answer" entries for one queue call), and the
ratio grows with queue size.

The conditions are back on by default and skipped only when the caller sets
expandLegs, mirroring how removeLostCalls is already plumbed through
req.params -> obj -> data. Only the middleware sets it, because it is the
only caller that collapses the legs back into one row per linkedid.

Reference NethServer/dev#8105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the "all directions" branch let a caller ask for a call's legs, so the
same call was expandable with no direction filter and not expandable with
"Incoming" or "Internal": those branches hide the unanswered legs of a call
that someone did answer, which for a queue or a ring group are the very legs
the caller asked to expand.

The "lost" branches keep the condition unconditionally: there it is not a
deduplication but the definition of the filter, since a lost call is one
nobody answered.

Reference NethServer/dev#8105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tommaso-ascani

Copy link
Copy Markdown
Author

Issue 2 addressed in eea11a1: the incoming and internal branches now honour expandLegs too, so a call is expandable under every direction filter, not only with no filter.

The two lost branches deliberately keep the condition unconditionally — there it is not a deduplication but the definition of the filter (a lost call is one nobody answered), so dropping it would list answered calls as lost.

Issue 1 was already withdrawn above and is now moot: f74ba98 restored the deduplication by default, so every consumer other than the middleware receives exactly what it received before this PR.

With a direction filter a grouped call had nothing to expand. Every clause in
these queries matches leg by leg, and a direction filter keeps only the leg
that carries the trunk (switchboard) or the user's own extension (personal
view): a queue call's member legs run over Local/<ext>@from-queue channels and
never matched, so of the nine legs of one real queue call the incoming filter
returned four, and pruning the queue bookkeeping left a single row.

Under expandLegs the filter is now applied by linkedid, so any leg matching
brings back the whole call. Measured over 60 days on a test PBX: 61 -> 122
rows, and faster than before (0.004s vs 0.018s) since linkedid is indexed.

This also means a call the user took part in brings back the legs of the
colleagues involved — who else the queue rang, who answered instead — which is
what expanding a call is for. It applies only to callers passing expandLegs.

Reference NethServer/dev#8105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tommaso-ascani

Copy link
Copy Markdown
Author

Follow-up on issue 2: making those clauses conditional was not enough, and I had picked the wrong clause. A grouped call still would not expand under any direction filter, for a different reason — the filters match leg by leg.

The switchboard incoming branch selects rows whose channel is a trunk (plus the attended-transfer special case); a queue call's member legs run over Local/<ext>@from-queue channels and match neither. Measured on one real queue call: 9 legs total, 4 returned by the incoming filter, and pruning the queue bookkeeping left a single row. The personal branches truncate the same way, matching only cnum/dst against the user's own endpoints.

Fixed in 58c70c7: under expandLegs the filter is applied by linkedid, so any matching leg brings back the whole call. Over 60 days on the test PBX that is 61 -> 122 rows, and faster than before (0.004s vs 0.018s) since linkedid is indexed. Verified working on the box with the Incoming filter.

Worth a second opinion on one consequence: in the personal view a call the user took part in now brings back the legs of the colleagues involved (who else the queue rang, who answered instead). That is what expanding a call is for and what NethServer/dev#7360 asks for, but it is a visibility change, and it is gated behind expandLegs so only the middleware gets it. Happy to restrict it to the switchboard query if you would rather keep the personal history to the user's own legs.

tommaso-ascani and others added 2 commits September 14, 2026 16:20
Selecting whole calls by linkedid was applied to both history queries. On the
personal view that returned the legs between the user's colleagues too — for
one queue call, 7 legs instead of 2 — which is the detail the switchboard view
exists for, and which that endpoint gates behind the "switchboard cdr"
authorization the personal one does not require.

The personal query goes back to matching the legs the user is a party to, as
it does on the release branch. The switchboard query keeps selecting whole
calls, so a filtered call still expands there.

Reference NethServer/dev#8105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…'s legs

A ring group dials all its members from the same channel, so their CDR rows
share one uniqueid and the ones that ended the same way were aggregated into a
single row before the caller ever saw them: the ringing members were lost, and
the non-aggregated columns of the survivor came from an arbitrary leg. Grouping
also by the destination channel — the member that was rung — keeps one row per
leg, and only for a caller that asked to expand them.

Reference NethServer/dev#8105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants