Skip to content

feat(support): dashboard backend (statistics, escalation alerts, clerk mapping)#3942

Merged
TaprootFreak merged 15 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/support-dashboard-backend
Jun 30, 2026
Merged

feat(support): dashboard backend (statistics, escalation alerts, clerk mapping)#3942
TaprootFreak merged 15 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/support-dashboard-backend

Conversation

@joshuakrueger-dfx

Copy link
Copy Markdown
Collaborator

Support dashboard — backend

Adds the backend for the new support dashboard overview: ticket statistics, Telegram escalation alerts, and account→clerk resolution.

What's included

  • StatisticsGET support/issue/statistics?days=N: role-scoped, period-based aggregates (total, avg messages/ticket, tickets/day, and a day- or month-bucketed trend depending on the period). MSSQL-side aggregation, no row dump.
  • Escalation alerts — a 5-minute cron notifies a Telegram group whenever a ticket escalates (customer waiting beyond Config.support.escalation.slaHours = 24h). Auto-binds to the group it was invited to, de-duplicates per waiting cycle, and posts a clickable link to the ticket. Bot token from SUPPORT_TELEGRAM_BOT_TOKEN; chat id + notified state via SettingService (no schema change).
  • Clerk resolutionGET support/issue/clerk returns the clerk mapped to the logged-in account via the new registered supportClerkAccounts setting ([{ account, name }]). Replaces client-side identity.

Setup (settings / ops)

  • Set env SUPPORT_TELEGRAM_BOT_TOKEN.
  • Invite the bot (@DFXsupport_bot) into the support Telegram group (it auto-binds within ~5 min).
  • Configure clerks (incl. Josh):
    • supportClerks (names for the assign dropdown): ["Jana","Vesa","Cyrill","Josh"]
    • supportClerkAccounts (account→name for "my tickets"): [{ "account": <JanaId>, "name": "Jana" }, { "account": <VesaId>, "name": "Vesa" }, { "account": <CyrillId>, "name": "Cyrill" }, { "account": <JoshId>, "name": "Josh" }]

Release Checklist

Pre-Release

  • Check migrations — none (settings only, no schema change)
  • Check for linter errors — tsc + ESLint clean
  • Test basic user operations (login/logout, buy/sell, KYC) on dev

Post-Release

  • Verify a test escalation reaches the Telegram group (POST support/issue/escalation/telegram-test)
  • Monitor application insights log

Pairs with the frontend PR DFXswiss/services (support dashboard overview). Merge/deploy this first for accurate stats; the frontend has a client-side fallback otherwise.

…k mapping)

- add GET support/issue/statistics?days=N with role-scoped, period-based aggregates (total, avg messages/ticket, tickets/day, day/month trend, avg resolution time per type)

- notify a Telegram group when a ticket escalates (customer waiting beyond the SLA) and for each new limit increase request; auto-bind + per-cycle de-duplication; chat id + state via SettingService; token from SUPPORT_TELEGRAM_BOT_TOKEN

- resolve the logged-in agent's clerk via GET support/issue/clerk, backed by the registered supportClerkAccounts setting ([{ account, name }])
@joshuakrueger-dfx joshuakrueger-dfx force-pushed the feat/support-dashboard-backend branch from 043dce7 to a768c04 Compare June 22, 2026 10:26

@TaprootFreak TaprootFreak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review notes on the support dashboard backend — solid overall (clean Setting/entity patterns, guarded token usage, role-scoped endpoints). A few items inline, mainly the OPEN_STATES interaction with the new states from #3950, the monthly-trend bucket math, and the missing .env.example entry.

const CHAT_ID_KEY = 'supportEscalationChatId';
const NOTIFIED_KEY = 'supportEscalationNotified';
const LIMIT_NOTIFIED_KEY = 'supportLimitRequestNotified';
const OPEN_STATES = [SupportIssueInternalState.CREATED, SupportIssueInternalState.PENDING];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

OPEN_STATES excludes the new ticket states introduced by #3950

The escalation cron only scans find({ where: { state: In(OPEN_STATES) } }) with OPEN_STATES = [CREATED, PENDING], and notifyNewLimitRequests reuses that same issues set. Once #3950 lets staff set InProgress / InClarification, a customer who then waits beyond the SLA is never escalated to Telegram, because the ticket is no longer in the queried set — and closeIssue/customer auto-reopen only resets Completed/OnHold/Canceled to Pending, so those states never return to an open state on their own. The services overview hardcodes the identical pair, so the ticket also vanishes from the dashboard. Suggest "all non-terminal states" and keeping both repos in sync.

if (department) trendQb.andWhere('issue.department = :department', { department });
const rows = await trendQb.getRawMany<{ y: number; m: number; count: string }>();
const map = new Map(rows.map((r) => [`${r.y}-${pad(r.m)}`, +r.count]));
const months = Math.max(1, Math.round(days / 30));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Monthly trend buckets cover fewer months than the period, so trend never sums to total

months = Math.max(1, Math.round(days / 30)) emits that many whole calendar months ending in the current month, while total counts every ticket with created >= from (from = now - days*24h). The oldest partial month(s) of the window therefore get no bucket: their tickets are in total but dropped from trend. Worst near the daily cutoff — e.g. days=32round(32/30)=1 bucket although the window spans 2–3 months; days=365 → 12 buckets although the window touches 13 months. The client mirror in services has the same off-by-one. Consider deriving the bucket list from from/now rather than round(days/30).

Comment thread src/config/config.ts
},
issueOnHoldExpiry: 14, //days
escalation: {
telegramBotToken: process.env.SUPPORT_TELEGRAM_BOT_TOKEN,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

New env var not added to .env.example

SUPPORT_TELEGRAM_BOT_TOKEN is introduced here but .env.example (which lists the other SUPPORT_* / token vars) is not updated in this PR. Per CONTRIBUTING → PR Completeness → Environment/Infrastructure updates, new env vars must ship in the same PR ("Missing any of these = changes requested"). Add a SUPPORT_TELEGRAM_BOT_TOKEN= entry near the other support vars.

// Reads recent updates and returns the group/supergroup chats the bot has seen.
async getGroupChats(): Promise<TelegramChat[]> {
if (!this.token) return [];
const res = await this.http.get<{ result: any[] }>(this.apiUrl('getUpdates'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

any violates the no-any rule (CONTRIBUTING → Type safety). The Telegram getUpdates payload is typed { result: any[] }, and the update loop var is implicitly any. A small interface for the fields actually read (message.chat, my_chat_member.chat, channel_post.chat with id / type / title) keeps it type-safe.

@GetJwt() jwt: JwtPayload,
@Query('days') days?: string,
): Promise<SupportIssueStatisticsDto> {
return this.supportIssueService.getSupportIssueStatistics(jwt.role, days ? +days : undefined);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-numeric ?days bypasses the clamp and yields NaN

days ? +days : undefined forwards NaN for a truthy non-numeric value (e.g. ?days=abc). The service default only applies on undefined, and the intended clamp Math.min(Math.max(Math.round(periodDays), 1), 366) propagates NaN (so it doesn't sanitize) → from becomes Invalid Date and perDay/periodDays come back NaN. Guard with Number.isFinite (or coerce to the default) before clamping. Low severity since the endpoint is role-gated.

// Convenience: pick the most recently seen group and store it as the escalation target.
async bindGroupChat(): Promise<TelegramChat | undefined> {
const chats = await this.getGroupChats();
const target = chats[chats.length - 1];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Auto-bind picks an arbitrary group, risking customer PII to an unintended chat

bindGroupChat stores chats[chats.length - 1] — the last group seen via getUpdates. If the bot is in (or briefly added to) more than one group, escalation messages — which include customer names and issue types — can be sent to the wrong chat. Consider binding only on an explicit "bot added to group" event for the intended group, or confirming the bound chat out-of-band before sending.

…nt escalation env var

- replace the any-typed getUpdates payload with TelegramUpdate/TelegramApiChat interfaces
- coerce a non-finite ?days to the default so the stats clamp cannot produce an Invalid Date
- add SUPPORT_TELEGRAM_BOT_TOKEN to .env.example
…issue tests

- include InProgress/InClarification in escalation OPEN_STATES so actively-handled tickets keep escalating
- span every calendar month the stats window touches so the monthly trend sums to total (was round(days/30))
- align getSupportIssueStatistics department scoping with the getVisibleDepartments helper its siblings use (RoleDepartmentMap path was dropped on develop)
- replace the tautological sort-injection test with a real DTO @IsEnum rejection test
- cover the anonymous UID close path and the CANCELED idempotency case
- document the SupportIssueListOrderBy column-name enum values
…stics tests

- replace MSSQL YEAR()/MONTH() with EXTRACT(... FROM ...) so the monthly statistics query runs on Postgres (the default /statistics path errored otherwise)
- include the oldest partial day in the daily trend so it sums to total, mirroring the monthly span
- add getSupportIssueStatistics coverage (daily bucket span, monthly granularity, non-numeric days fallback)
- describe the list DTO updated field by what it is, not a stats implementation detail
…aming and contracts

- group the statistics trend by CAST(created AS DATE) and roll up to months in JS, replacing the Postgres-incompatible YEAR()/MONTH() (and the alias-guard-failing EXTRACT) so the default /statistics path runs
- rename SupportClerkDto -> SupportClerkAccountDto, SupportIssueStatBucketDto -> SupportIssueStatisticsBucketDto, the German label maps to ...Map, and getMyClerk -> getSupportIssueClerk for consistency
- wrap the telegram-bind response as { chat } to match the sibling endpoints
… real data

- derive trend map keys from the same local date parts as the bucket loop (and dedupe the shared day-grouped query), so daily and monthly buckets align in any timezone
- replace the all-zero statistics assertion with a non-empty case that pins counts to their calendar-day buckets and a non-trivial sum-to-total
…r resolution and scoping

- anchor the daily trend's first bucket to from's calendar day (not a fixed count) so it stays correct across DST/timezone shifts and always sums to total
- use getObj's default-value argument instead of ?? {} / ?? [] in the escalation cron, matching the codebase idiom
- add statistics tests for resolution-time-per-type averaging and department scoping
…overage

- rename getSupportClerkForAccount -> getSupportIssueClerkForAccount to match the getSupportIssue* method family
- extract a ticketLink helper and read the bound chat id via getBoundChatId in the escalation cron
- drop the redundant updated Swagger description so it matches the sibling created field
- add a monthly trend test asserting count placement and sum-to-total (mirroring the daily case)
@joshuakrueger-dfx

Copy link
Copy Markdown
Collaborator Author

@TaprootFreak Danke fürs Review — alle sechs Punkte sind adressiert:

  1. OPEN_STATES (escalation cron) — jetzt alle nicht-terminalen States inkl. InProgress/InClarification; OnHold bleibt geparkt ausgenommen. notifyNewLimitRequests nutzt dieselbe Menge.
  2. Monatliche Trend-Buckets — Bucket-Liste wird aus from/now abgeleitet (monthSpan = (now.y-from.y)*12 + (now.m-from.m)), nicht mehr round(days/30); trend summiert jetzt immer zu total. Daily-Pfad ist auf froms Kalendertag verankert.
  3. SUPPORT_TELEGRAM_BOT_TOKEN — in .env.example bei den anderen SUPPORT_*-Vars ergänzt.
  4. any in getUpdates — durch TelegramUpdate/TelegramApiChat-Interfaces ersetzt (nur die gelesenen Felder).
  5. Nicht-numerisches ?days — der Service guardet mit Number.isFinite(periodDays) vor dem Clamp und fällt sonst auf den Default zurück, also kein NaN/Invalid Date mehr.
  6. bindGroupChat PII-Risiko — neu in 8a5b5dbd1: kein willkürliches chats[last] mehr. Auto-Bind nur, wenn genau eine Gruppe den Bot explizit eingeladen hat (my_chat_member → joined status); sonst bleibt es ungebunden (mit Warn-Log). Zusätzlich expliziter chatId-Pfad (POST escalation/telegram-bind mit BindEscalationChatDto), damit ein Operator eine aus telegram-chats geprüfte Gruppe deterministisch bindet. Abgedeckt durch support-escalation.service.spec.ts (5 Tests: Single-Invite, Mehrdeutig→kein Bind, nur-gesehen→kein Bind, expliziter Treffer, expliziter Fehlschlag).

Lint + Specs lokal grün (29 Tests in den beiden betroffenen Spec-Files), CI läuft auf dem neuen Commit.

Auto-bind only when exactly one group has explicitly invited the bot
(my_chat_member -> joined status), instead of picking the last group the
bot happened to see. Add an explicit chatId path so an operator can bind a
reviewed group out-of-band. Prevents escalation messages (which carry
customer names/issue types) from being sent to an unintended chat.
@joshuakrueger-dfx joshuakrueger-dfx force-pushed the feat/support-dashboard-backend branch from 8a5b5db to ec6ee84 Compare June 30, 2026 17:12
The daily/monthly bucket keys align because the app and DB both run UTC; soften the comments that overclaimed correctness in any timezone.
@TaprootFreak

TaprootFreak commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Please stop force-pushing this branch while it's being co-developed

This branch was force-pushed again today (~19:12 CEST), rewriting an already-pushed commit (the escalation-bind change) to fix its formatting. Since a couple of us have been pushing here in parallel, this is a real process problem, not a nitpick:

  • It can drop others' commits. A force-push overwrites shared history. While I was pushing fixes to this branch, a force-push could have silently discarded them — this time they survived, but that must not be left to luck.
  • It breaks collaborators' clones and in-flight CI/reviews. Anyone with the branch then has to hard-reset (a plain git pull fails), and pending CI runs / reviews are invalidated against history that no longer exists. The red-CI/format-check confusion earlier today traced back to exactly this.

Per our contributing guidelines, a force-push is only acceptable for a genuine rebase of an unmerged feature branch onto its base — never to amend an already-pushed commit. For a follow-up fix (e.g. a formatting tweak), push a new commit; everything is squashed at merge anyway, so a tidy intermediate history is no reason to rewrite a shared branch.

Going forward, please do not force-push this branch — no --amend / --force / reset+push on commits that are already pushed. Push follow-up commits instead. If the branch ever genuinely needs a rebase onto develop, coordinate first so nobody's in-progress work gets overwritten.

Thanks 🙏

Add checkEscalations cases: an overdue customer-waiting ticket escalates exactly once and records the waiting cycle; a second run on the same cycle is de-duplicated (no send) while keeping the marker.
- add a checkEscalations case asserting no escalation when support (not the customer) wrote the last message
- assert the daily trend spans from's calendar day through today instead of a fixed length, so it stays correct across timezone/DST shifts
@joshuakrueger-dfx

Copy link
Copy Markdown
Collaborator Author

You're completely right, and I apologize — the --amend + force-push to fix the prettier formatting rewrote an already-pushed commit on a shared branch. That was the wrong call regardless of --force-with-lease, and it's exactly what caused the earlier red-CI/format-check churn.

Understood and adopted going forward on this branch:

  • No force-push / --amend / reset+push on commits that are already pushed here.
  • Follow-up fixes (formatting, review tweaks) go in as new commits — squash happens at merge anyway.
  • A genuine rebase onto develop only after coordinating first, never unannounced.

I'll leave the current head (ec6ee84c1, CI green) as-is rather than rewrite again, and only add commits from here. Thanks for flagging it clearly. 🙏

…ten injection test

- order the support-escalation src imports path-alphabetically (models before services), matching sibling services
- rename SupportIssueResolutionDto -> SupportIssueStatisticsResolutionDto so both nested statistics shapes share the parent's Statistics infix
- assert the orderBy injection test fails specifically on the @IsEnum constraint
…at id

- the cron no longer auto-binds; escalations run only against a chat an operator deliberately bound (closes a PII-leak race where an outsider could be the sole bot-inviter during the deploy-before-bind window)
- on a supergroup migration, rebind to the new chat id and resend; on a permanently unreachable chat (group deleted / bot removed), drop the binding so an operator rebinds, instead of erroring every 5-minute run forever
- cover both self-healing paths plus the unbound no-op with unit tests
@TaprootFreak TaprootFreak merged commit 79ee5c4 into DFXswiss:develop Jun 30, 2026
5 checks passed
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