Files
agent-desk-ng/docs/features/support-availability.md
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
2026-10-16 15:21:42 +00:00

79 lines
12 KiB
Markdown

# Human support availability and calendars
This change separates platform presence, working availability, account enablement, and team opening hours. It does not move, close, or reassign existing conversations when an operator goes on leave or becomes unavailable.
## Presence is a live authenticated connection
Only authenticated employee connections to the dashboard or dashboard-notification WebSocket establish presence. The notification connection is mounted across the dashboard and workbench. Visitor WebSockets, conversation typing/viewing events, enabled account records, login history, and `LastOnlineAt` do not establish operator presence.
Each connection obtains a server-generated random lease ID. The database stores that ID, user ID, internal login-session ID, and expiration, never the bearer token or client IP. Authenticated application pings or protocol pongs renew the lease at most once every 15 seconds. The lease lasts at most 90 seconds and never outlives the login's absolute expiration. A clean disconnect shortens only that connection's lease to at most 15 seconds; other tabs/devices remain effective. A process crash or lost network is bounded by the 90-second lease, subject to synchronized server clocks. Closed connections cannot renew a lease afterward. Expired rows are pruned by the existing pending-dispatch loop.
Every eligibility/presence read checks current session expiration/revocation, account status/deletion/type, and current conversation-view permission. A logout or removed privilege therefore does not wait for the lease TTL. Browser-background tabs still count as an open platform session; this is connectivity, not a claim that a person is looking at the screen. The bounded grace/TTL avoids false offline flicker without claiming instantaneous disconnect detection.
Leases use the shared application database, not a per-process map, and unique connection IDs prevent collisions across replicas. All replicas must share that database and have synchronized clocks. This does not add distributed WebSocket message fanout, which is a separate pre-existing deployment concern.
## Working state and dated absence
Existing numeric values remain compatible: Idle/ready = 0 and Busy = 1. New values are Break = 2, Unavailable = 3, and Vacation = 4. Account/profile/team disabled status remains separate.
Vacation requires explicit-offset RFC3339 start and end timestamps. The interval is half-open: start inclusive, end exclusive. Scheduling vacation captures the current manual state as `returnServiceStatus`; that state remains effective before the start and after the end. Updating an already scheduled vacation preserves that return state. A later manual Ready/Busy/Break/Unavailable choice cancels the interval. No timer is required: all evaluation derives the effective state from the current instant. Raw `serviceStatus` may retain Vacation after the interval as configuration/history; `effectiveServiceStatus` is authoritative for badges, readiness, routing, and effective-status list filters.
`POST /api/dashboard/support_availability/me` always targets the authenticated user's existing enabled employee operator profile. A supplied `userId` cannot change the target or create operator privileges. Administrative state changes require `agent.update`. Generic profile edits preserve working state so a stale profile form cannot cancel newly scheduled leave.
`ReceiveOfflineMessage` is retained for database/API compatibility but deprecated and nonfunctional for live routing. It never overrides presence, leave, calendar, enabled-state, or capacity checks. Offline requests stay in the existing pending pools; no new delivery or assignment promise is attached to that old flag.
## One authoritative calendar per team
`SupportCalendar` contains one optional row per team. Team ID 0 is deployment-wide timezone configuration only, not an organization and not an implicit schedule. The default for new explicitly enabled recurring calendars is the explicit IANA zone `UTC`; a team can specify another IANA timezone or inherit the deployment default. The Go binary embeds timezone data. Docker's historical `Asia/Shanghai` setting is not evidence of the active server's timezone and is not used to reinterpret saved shifts.
Modes:
- `legacy` (shown as dated shifts): the default when a team has no new calendar row. Existing `AgentTeamSchedule` rows are evaluated as the exact stored instants. Their existing process-local input/display convention is unchanged. The response labels the real legacy process zone, which can be `Local`, rather than pretending it is a new IANA setting.
- `recurring`: an explicit administrator opt-in. Only the saved weekly rules and date exceptions are used. Existing dated rows are retained but inactive. Switching back to dated-shift mode uses those unchanged rows again. There is no silent conversion, bulk backfill, year-long shift, or schedule merging.
`deploymentTimezone` always identifies the new configured default independently of the legacy display zone. A legacy-to-recurring editor must use this field, not reinterpret the legacy zone. Preview returns the evaluated mode, timezone, open flag, and an explicit UTC RFC3339 next-opening instant when known.
Weekly rules use ISO weekdays 1 (Monday) through 7 (Sunday), with one or more HH:mm intervals per day. `24:00` is allowed only as an end boundary. Equal start/end is rejected; end before start denotes an overnight interval. Split shifts are supported. A date exception replaces that entire civil date: `closed` means a holiday/closure, otherwise its intervals replace the weekly intervals. An exception also cuts off a preceding overnight carry at midnight. Overlapping intervals are treated as their union, never duplicate capacity.
DST is defined, not left to host normalization: a nonexistent boundary advances to the first valid local minute; an ambiguous start uses the earlier occurrence and an ambiguous end the later occurrence. A fold-spanning interval is continuous between those instants. An interval entirely swallowed by a forward jump has no duration and is closed. Tests cover New York hour transitions and Lord Howe half-hour transitions.
For recurring calendars the next-opening search is bounded to 366 future civil days. A missing `nextOpeningAt` means no opening was found in that horizon, not a guarantee of permanent closure. Dated mode uses the earliest stored future opening. A calendar opening is not a promise that a ready operator will be online then, and no waiting-time estimate is invented.
## Eligibility, assignment, and public status
The shared eligibility evaluator is used by automatic candidates, manual assignment/transfer target lists and commits, readiness/simulation, and public status. It checks profile, employee account, team enablement, the authoritative calendar, authenticated presence, effective working state, automatic-assignment opt-in when appropriate, and capacity. Manual assignment does not require auto-assign opt-in, but cannot bypass the other checks or the conversation AI agent's team scope. Empty configured team IDs retain the existing all-enabled-team fallback.
`maxConcurrentCount = 0` consistently means unlimited, as in the original dispatcher. Negative values remain invalid. Assignment and transfer acquire the target profile's portable database write lock before the transaction's first consistent read, then reload eligibility and active load before committing. SQLite serializes writes; MySQL acquires the target row lock before establishing the repeatable-read snapshot. A conditional conversation update prevents concurrent targets from both winning. Changing availability does not consume or silently relinquish existing conversations.
The unauthenticated `GET /api/support/status` resolves the enabled channel through the existing channel header and returns only `available`, `reason`, `timezone`, and optional `nextOpeningAt`. It is unrelated to the visitor's own WebSocket connection indicator. No employee identities, absence dates, private absence reasons, team counts, or invented wait times are returned. The endpoint uses the existing per-address public rate-limit middleware (120 requests per configured window).
Public reason precedence is:
1. `available`: at least one currently eligible automatically routable human.
2. `outside_hours`: no enabled routed team has an open calendar. If known, report the earliest opening and its matching timezone.
3. `capacity`: calendars are open and at least one otherwise eligible operator is at capacity.
4. `temporarily_absent`: other cases such as disconnected, busy, on leave, unavailable, or no usable human configuration.
Existing pending team/global pools and the periodic dispatch loop are reused. New live presence, state saves, and calendar saves may trigger another pending scan. SLA engines, a new fair queue, automatic vacation handover, and guaranteed staffing forecasts are not part of this change.
## API and permissions
All endpoints retain the shared JSON result envelope.
- GET/POST `/api/dashboard/support_availability/me`: authenticated staff, own existing profile only; GET returns null when there is no profile.
- GET `/api/dashboard/support_availability/agents`: `agent.view`, `conversation.assign`, or `conversation.transfer`. `conversationId` restricts target teams and applies conversation-view/transfer access checks; it is required when the caller lacks `agent.view`.
- POST `/api/dashboard/support_availability/agent`: `agent.update`, explicit `userId`.
- GET `/api/dashboard/support_availability/calendar?teamId=N`: `agentTeamSchedule.view` for teams; `supportConfig.view` for deployment ID 0.
- POST `/api/dashboard/support_availability/calendar`: `agentTeamSchedule.update` for teams; `supportConfig.update` for deployment ID 0.
- POST `/api/dashboard/support_availability/calendar/preview`: corresponding read permission, no persistence.
The state DTO includes both configured/effective state, return state, own/admin absence bounds, presence, eligibility reason, effective calendar, and next calendar opening. Calendar DTOs include weekly/exception structures, the explicit override, effective timezone, and deployment timezone. New enum values are generated from `internal/pkg/enums/agent.go`, not handwritten frontend duplicates.
## Rollout, schema, and validation
Startup AutoMigrate registers the new `SupportCalendar` and `AgentPresenceLease` tables and adds `absence_start_at`, `absence_end_at`, `availability_return_status`, and `assignment_version` to agent profiles. Normal SQLite/MySQL GORM mappings and indexes are used; no versioned destructive migration or seeded schedule is needed. Back up the deployment database and review the migration in staging before release. This work has not run a migration, restarted a service, or sent a real customer message on the working server.
Roll out backend/frontend/replicas together: an old binary would not enforce the new presence and vacation rules. After startup, operators must have a connected authenticated dashboard notification socket before receiving new work. Initially all teams retain dated schedules; configure the deployment timezone and review each team's preview before explicitly enabling recurring mode. Existing conversation owners are preserved. Rolling back to an old binary requires operational care because it will not honor the new eligibility semantics even if added columns/tables remain harmless.
Validation uses temporary SQLite databases, pure evaluator tests, and httptest requests, never production data. Focused tests cover split/overnight shifts, holidays, explicit opt-in, DST folds/gaps, bounded next openings, multi-tab/device aggregation, expiration/revocation/privilege removal, absence boundaries, capacity guards, channel-scoped privacy, and self/admin authorization. MySQL live concurrency and a real deployed browser/WebSocket liveness scenario still require staging validation; portable locking and SQL have been reviewed but not claimed as production-tested.