Compare commits
1 Commits
main
...
hardening-
| Author | SHA1 | Date | |
|---|---|---|---|
| 63e0457886 |
@@ -1,75 +1,40 @@
|
||||
# Modified-file notice
|
||||
|
||||
This independent fork modifies the source snapshot from
|
||||
This independent hardening fork modifies the source snapshot from
|
||||
MonoVerseNS/agent-desk-ng revision
|
||||
`b06954b0a495389cbb5194e0d388342eabe60636` on 2026-10-16.
|
||||
The initial Gitea snapshot `e57610a27c556e005b9b3b68115477ba1fdd3f1b`
|
||||
has the same source tree without importing the old upstream history.
|
||||
Upstream Apache-2.0 licensing and notices are retained.
|
||||
Upstream Apache-2.0 licensing and notices are retained. Changes are described in
|
||||
[the hardening notes](docs/hardening/README.md); their validation limits are
|
||||
[listed separately](docs/hardening/VALIDATION.md).
|
||||
|
||||
Changes include security and chat recovery, knowledge localization, operator
|
||||
availability, calendars, and supporting lifecycle fixes. See
|
||||
[hardening notes](docs/hardening/README.md),
|
||||
[knowledge localization](docs/knowledge-localization.md), and
|
||||
[availability](docs/features/support-availability.md). Actual validation and
|
||||
remaining release gates are [recorded separately](docs/hardening/VALIDATION.md).
|
||||
This inventory is not a production-release or deployment claim.
|
||||
|
||||
Modified source files carry a change notice. JSON catalogs retain their
|
||||
accompanying `.license` notices; generated enums retain their generator header
|
||||
and are identified here.
|
||||
Modified source files carry a change notice. JSON translation catalogs retain
|
||||
valid JSON and have accompanying `.license` notices. This list identifies the
|
||||
modified and added files; it does not assert that unexecuted tests have passed.
|
||||
|
||||
## Modified upstream files
|
||||
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `internal/ai/rag/answer.go`
|
||||
- `internal/ai/rag/answer_test.go`
|
||||
- `internal/ai/rag/index.go`
|
||||
- `internal/ai/rag/index_retrieve_helpers_test.go`
|
||||
- `internal/ai/rag/retrieve.go`
|
||||
- `internal/ai/rag/retrieve_context.go`
|
||||
- `internal/ai/rag/retrieve_search.go`
|
||||
- `internal/bootstrap/routes.go`
|
||||
- `internal/bootstrap/server.go`
|
||||
- `internal/builders/conversation_builder.go`
|
||||
- `internal/builders/knowledge_builder.go`
|
||||
- `internal/handlers/api/customer_handler.go`
|
||||
- `internal/handlers/dashboard/agent_handler.go`
|
||||
- `internal/handlers/dashboard/knowledge_base_handler.go`
|
||||
- `internal/handlers/dashboard/knowledge_directory_handler.go`
|
||||
- `internal/handlers/dashboard/knowledge_document_handler.go`
|
||||
- `internal/handlers/dashboard/knowledge_faq_handler.go`
|
||||
- `internal/handlers/dashboard/knowledge_retrieve_handler.go`
|
||||
- `internal/handlers/dashboard/knowledge_retrieve_log_handler.go`
|
||||
- `internal/models/models.go`
|
||||
- `internal/pkg/dto/dto.go`
|
||||
- `internal/pkg/dto/request/setup_readiness_req.go`
|
||||
- `internal/pkg/dto/response/setup_readiness_resp.go`
|
||||
- `internal/pkg/enums/agent.go`
|
||||
- `internal/pkg/eventbus/bus.go`
|
||||
- `internal/pkg/eventbus/bus_test.go`
|
||||
- `internal/pkg/i18nx/context.go`
|
||||
- `internal/pkg/i18nx/locales/en-US.yml`
|
||||
- `internal/pkg/i18nx/locales/ru-RU.yml`
|
||||
- `internal/pkg/i18nx/locales/zh-CN.yml`
|
||||
- `internal/pkg/openidentity/external_identity_test.go`
|
||||
- `internal/pkg/openidentity/openidentity.go`
|
||||
- `internal/repositories/customer_identity_repository.go`
|
||||
- `internal/services/agent_profile_service.go`
|
||||
- `internal/services/auth_service.go`
|
||||
- `internal/services/channel_service.go`
|
||||
- `internal/services/conversation_dispatch_service.go`
|
||||
- `internal/services/conversation_human_dispatch_realtime_test.go`
|
||||
- `internal/services/conversation_human_dispatch_service_test.go`
|
||||
- `internal/services/conversation_service.go`
|
||||
- `internal/services/customer_service.go`
|
||||
- `internal/services/customer_session_service.go`
|
||||
- `internal/services/dashboard_service.go`
|
||||
- `internal/services/knowledge_base_service.go`
|
||||
- `internal/services/knowledge_base_service_test.go`
|
||||
- `internal/services/knowledge_faq_excel.go`
|
||||
- `internal/services/knowledge_faq_excel_test.go`
|
||||
- `internal/services/setup_readiness_service.go`
|
||||
- `internal/services/setup_readiness_service_test.go`
|
||||
- `internal/services/ws_connection_manager.go`
|
||||
@@ -77,51 +42,23 @@ and are identified here.
|
||||
- `internal/services/ws_service.go`
|
||||
- `internal/services/ws_service_test.go`
|
||||
- `web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx`
|
||||
- `web/app/(dashboard)/dashboard/agent-team-schedules/page.tsx`
|
||||
- `web/app/(dashboard)/dashboard/agents/_components/edit.tsx`
|
||||
- `web/app/(dashboard)/dashboard/agents/page.tsx`
|
||||
- `web/app/(dashboard)/dashboard/channels/_components/edit.tsx`
|
||||
- `web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx`
|
||||
- `web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/_components/debug-panel.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/_components/document-list.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/_components/faq-list.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/_components/knowledge-content-detail.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/_components/retrieve-log-detail.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/_components/retrieve-log-list.tsx`
|
||||
- `web/app/(dashboard)/dashboard/knowledge/page.tsx`
|
||||
- `web/app/(dashboard)/dashboard/setup/_components/setup-wizard-view.tsx`
|
||||
- `web/app/(support)/support/chat/closed/page.tsx`
|
||||
- `web/components/agent-realtime-provider.test.mjs`
|
||||
- `web/components/auth-provider.tsx`
|
||||
- `web/components/chat/shared-message-editor.tsx`
|
||||
- `web/components/content-editor/html-editor.tsx`
|
||||
- `web/components/content-editor/index.tsx`
|
||||
- `web/components/conversation-actions/transfer-dialog.tsx`
|
||||
- `web/components/nav-main.test.mjs`
|
||||
- `web/components/notification-provider.tsx`
|
||||
- `web/components/operator-readiness-indicator.tsx`
|
||||
- `web/components/option-combobox.tsx`
|
||||
- `web/components/palette-toggle.tsx`
|
||||
- `web/components/session-provider.tsx`
|
||||
- `web/components/site-header.tsx`
|
||||
- `web/components/support-chat/chat-shell.tsx`
|
||||
- `web/components/support-chat/close-navigation.test.mjs`
|
||||
- `web/components/support-chat/close-navigation.ts`
|
||||
- `web/components/support-chat/connection-status.tsx`
|
||||
- `web/components/support-chat/customer-message-editor.tsx`
|
||||
- `web/components/workbench-header.tsx`
|
||||
- `web/components/workspace-switcher.test.mjs`
|
||||
- `web/hooks/use-agent-conversation-realtime.ts`
|
||||
- `web/lib/api/auth.ts`
|
||||
- `web/lib/api/client.ts`
|
||||
- `web/lib/api/im.ts`
|
||||
- `web/lib/api/setup-readiness.ts`
|
||||
- `web/lib/generated/enums.ts`
|
||||
- `web/lib/knowledge-i18n.test.mjs`
|
||||
- `web/lib/knowledge-i18n.ts`
|
||||
- `web/lib/navigation.tsx`
|
||||
- `web/lib/realtime-connection.test.mjs`
|
||||
- `web/lib/realtime-connection.ts`
|
||||
- `web/lib/stores/agent-conversations.ts`
|
||||
@@ -133,54 +70,24 @@ and are identified here.
|
||||
## Added files
|
||||
|
||||
- `MODIFIED_FILES.md`
|
||||
- `docs/features/support-availability.md`
|
||||
- `docs/fixes/chat-realtime-recovery.md`
|
||||
- `docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md`
|
||||
- `docs/hardening/README.md`
|
||||
- `docs/hardening/SETUP_PREVIEW.md`
|
||||
- `docs/hardening/VALIDATION.md`
|
||||
- `docs/knowledge-localization.md`
|
||||
- `docs/support-availability-ui.md`
|
||||
- `internal/ai/rag/faq_content.go`
|
||||
- `internal/ai/rag/localization_test.go`
|
||||
- `internal/builders/knowledge_builder_i18n_test.go`
|
||||
- `internal/handlers/api/support_status_handler.go`
|
||||
- `internal/handlers/dashboard/knowledge_retrieve_handler_test.go`
|
||||
- `internal/handlers/dashboard/support_availability_handler.go`
|
||||
- `internal/handlers/dashboard/support_availability_handler_test.go`
|
||||
- `internal/models/support_availability.go`
|
||||
- `internal/pkg/dto/request/support_availability_req.go`
|
||||
- `internal/pkg/dto/response/support_availability_resp.go`
|
||||
- `internal/pkg/i18nx/context_test.go`
|
||||
- `internal/repositories/auth_scope_repository.go`
|
||||
- `internal/repositories/channel_config_repository.go`
|
||||
- `internal/repositories/conversation_dispatch_queries.go`
|
||||
- `internal/repositories/customer_identity_scope.go`
|
||||
- `internal/repositories/support_availability_repository.go`
|
||||
- `internal/services/agent_presence_service.go`
|
||||
- `internal/services/auth_session_security_test.go`
|
||||
- `internal/services/channel_policy_concurrency_test.go`
|
||||
- `internal/services/customer_identity_security_test.go`
|
||||
- `internal/services/support_availability_edge_review_test.go`
|
||||
- `internal/services/support_availability_service.go`
|
||||
- `internal/services/support_availability_service_test.go`
|
||||
- `internal/services/support_calendar_service.go`
|
||||
- `internal/services/test_network_test.go`
|
||||
- `internal/services/ws_auth_security_test.go`
|
||||
- `internal/services/ws_pump_security_test.go`
|
||||
- `internal/services/ws_session_concurrency_test.go`
|
||||
- `scripts/audit-customer-identity-scopes.sql`
|
||||
- `web/app/(dashboard)/dashboard/support-availability/_components/calendar-editor.tsx`
|
||||
- `web/app/(dashboard)/dashboard/support-availability/page.tsx`
|
||||
- `web/components/lifecycle-regressions.test.mjs`
|
||||
- `web/components/support-availability/lifecycle.test.mjs`
|
||||
- `web/components/support-availability/operator-status.tsx`
|
||||
- `web/components/support-availability/public-status.tsx`
|
||||
- `web/lib/api/im-session.test.mjs`
|
||||
- `web/lib/api/session-isolation.test.mjs`
|
||||
- `web/lib/api/setup-readiness.test.mjs`
|
||||
- `web/lib/api/support-availability.transport.test.mjs`
|
||||
- `web/lib/api/support-availability.ts`
|
||||
- `web/lib/message-editor-drafts.test.mjs`
|
||||
- `web/lib/message-editor-drafts.ts`
|
||||
- `web/lib/message-file-identity.test.mjs`
|
||||
@@ -190,12 +97,7 @@ and are identified here.
|
||||
- `web/lib/session-request-guard.test.mjs`
|
||||
- `web/lib/session-request-guard.ts`
|
||||
- `web/lib/stores/agent-conversations.test.mjs`
|
||||
- `web/lib/stores/support-chat-realtime.test.mjs`
|
||||
- `web/lib/stores/support-chat.test.mjs`
|
||||
- `web/lib/support-availability-i18n.ts`
|
||||
- `web/lib/support-availability.locale-edge.test.mjs`
|
||||
- `web/lib/support-availability.test.mjs`
|
||||
- `web/lib/support-availability.ts`
|
||||
- `web/lib/test-support/load-typescript-module.mjs`
|
||||
- `web/messages/en-US.json.license`
|
||||
- `web/messages/ru-RU.json.license`
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Support chat realtime recovery
|
||||
|
||||
This targeted change is based on canonical Gitea main snapshot
|
||||
`e57610a27c556e005b9b3b68115477ba1fdd3f1b` (tree
|
||||
`9cf938c2498ce6b63faaad7cf8b286c1cfe7a5ef`). That original targeted change did not import
|
||||
the separate hardening-review branch. The current integration reconciles it with that branch,
|
||||
preserving both realtime recovery and scoped identity/draft safeguards.
|
||||
|
||||
## Report and source findings
|
||||
|
||||
The reported scenario is opening a new chat from a previous one: sending works,
|
||||
but replies appear only after a page reload. Source inspection does **not** prove
|
||||
why the deployed WebSocket fails. The deployed version, reverse proxy, browser
|
||||
handshake and network have not been observed or tested for this report.
|
||||
|
||||
The source does establish these recovery gaps:
|
||||
|
||||
- A socket in `CONNECTING` has no deadline; subsequent create/select/send paths
|
||||
see it and return without starting a replacement.
|
||||
- HTTP fallback checks end after 1.5, 3.5 and 7 seconds, so a later reply is not
|
||||
retrieved while realtime remains unavailable.
|
||||
- Reusing an open socket on create/select bypasses the shared status repair path.
|
||||
- Initial history failure prevents bootstrap/retry from starting realtime.
|
||||
- A replaced socket's late close can clear the current heartbeat/subscription
|
||||
bookkeeping. A late close-conversation response can affect a newer selection.
|
||||
|
||||
## Changes
|
||||
|
||||
- Bound connection/authentication attempts to 15 seconds, invalidate abandoned
|
||||
attempts, and retry using the existing exponential reconnect policy.
|
||||
- Ignore close events from replaced sockets before touching current state.
|
||||
- Route new/selected chats through the existing subscription/connection helper.
|
||||
- Fetch late replies every 5 seconds only while the chat is open, visible, has
|
||||
an active nonclosed conversation, and realtime is not connected. Never label
|
||||
HTTP fallback as an online WebSocket.
|
||||
- Keep latest-message refresh single-flight with a 15-second abort deadline.
|
||||
Cancel previous-view requests and fence their completion before starting a new
|
||||
one. Ignore stale identity/view results, stop recovery on connection, hiding,
|
||||
close or teardown, and recover on focus, visibility or network return.
|
||||
Background recovery failures remain quiet. The optional API AbortSignal is
|
||||
backward-compatible with existing history/pagination callers.
|
||||
- Connect independently of initial HTTP history success and merge history so it
|
||||
cannot remove a reply already delivered by realtime.
|
||||
- Restrict a close response to the conversation it actually closed.
|
||||
|
||||
No backend, SDK bundle, credentials, database, deployment configuration or
|
||||
production data is changed.
|
||||
|
||||
## Validation status
|
||||
|
||||
Completed static checks only:
|
||||
|
||||
- `git diff --check`.
|
||||
- Isolated strict TypeScript compile of `web/lib/realtime-connection.ts` using an
|
||||
already installed TypeScript 6.0.3 compiler.
|
||||
- TypeScript syntax/transpile diagnostics for the four changed production files.
|
||||
- JavaScript syntax checks for the two focused regression files.
|
||||
|
||||
Added offline mock regressions for delayed replies beyond seven seconds, moving
|
||||
from a previous chat, resubscription, stuck handshakes, late authentication and
|
||||
socket close events, hidden/unmounted lifecycle, single-flight fallback,
|
||||
identity/view changes, abort deadlines, stalled previous-view fetches, history
|
||||
failures and late close responses.
|
||||
|
||||
The integrated 2026-10-16 baseline has now executed all 226 frontend Node tests,
|
||||
including these recovery regressions, and the declared-dependency frontend
|
||||
typecheck. Both passed. Focused backend security/setup tests and their race run
|
||||
also passed; see [the current validation record](../hardening/VALIDATION.md) for
|
||||
exact scope, fixture corrections and remaining build/lint/MySQL/browser gates.
|
||||
No production services were started, real support messages sent, or deployment
|
||||
performed. These checks are not a claim of production acceptance.
|
||||
@@ -6,14 +6,6 @@ This independent fork starts from the current source tree of
|
||||
The upstream Apache-2.0 license and notices remain applicable. This document
|
||||
records changes made by this fork; it is not a claim of production certification.
|
||||
|
||||
## Integration baseline
|
||||
|
||||
The isolated integration reconciles public Gitea main `299ea50f` with the reviewed
|
||||
hardening branch `63e04578`. It preserves main's bounded connection attempts,
|
||||
abortable ongoing HTTP recovery and System setup navigation alongside the
|
||||
identity/live-access/draft/setup safeguards. Only the clean public repository
|
||||
history is used. See `VALIDATION.md` for executed checks and unresolved gates.
|
||||
|
||||
## Release gate
|
||||
|
||||
The hardening changes must be reviewed and validated before deployment. Source
|
||||
|
||||
@@ -53,10 +53,4 @@ The simulator is read-only and sends no customer message. `candidate_operators`
|
||||
|
||||
Focused regression tests cover preview-only behavior, explicit apply, stale previews, atomic rollback, deleted/disabled/zero-rollout preservation, named-target scope, off-hours versus missing schedules, role-name permission bypass, status-only permissions, empty arrays, simulation limitations, publication requirements, and post-commit failures.
|
||||
|
||||
The focused setup regressions and their race-enabled run passed in the isolated
|
||||
2026-10-16 integration baseline. Go formatting/compilation, declared-dependency
|
||||
frontend typecheck and Node regression tests were also executed. The remaining
|
||||
combined-tree validation, frontend release build, actual MySQL upgrade and browser
|
||||
acceptance gates are recorded in [VALIDATION.md](VALIDATION.md). No production
|
||||
configuration or data was changed. Passing isolated tests does not establish
|
||||
production readiness or authorize an upgrade.
|
||||
At implementation handoff these tests have been written but not executed. Go compilation, formatting, dependency installation, application execution, database integration, and browser verification were not performed in this source-review environment. TypeScript source parsing, locale-key parity, and whitespace/diff checks have been performed. Those source checks are not substitutes for the deferred tests.
|
||||
|
||||
@@ -1,185 +1,47 @@
|
||||
# Validation status: isolated checks, not a production release
|
||||
# Validation status: review branch, not a release
|
||||
|
||||
Validated on 2026-10-16 against the integrated public-source baseline: Gitea main
|
||||
`299ea50fb5bb6874c3fca96277fce187205ac20a` and hardening-review
|
||||
`63e045788623e88f7fd7b44102e649e08f4c1f1c`, merged as `3868181`, with the follow-up
|
||||
corrections listed below. This baseline does not include later knowledge-base
|
||||
localization or operator-availability feature work. Its historical results are
|
||||
retained below; the final combined-tree checks are recorded separately at the end
|
||||
of this document.
|
||||
This source change is published for review. Do not deploy it as a verified
|
||||
security release until the checks below have been completed.
|
||||
|
||||
## Isolation and toolchain
|
||||
## Completed source checks
|
||||
|
||||
- Go 1.26.9, Node 24.19.0, pnpm 10.30.2, locked TypeScript 6.0.3/Next 16.2.11.
|
||||
- Official Go archive checksum and downloaded Go module checksums verified.
|
||||
Frontend dependencies installed with the frozen lockfile and lifecycle scripts
|
||||
disabled. Dependency manifests/lockfiles were not changed.
|
||||
- Tests ran with a cleared application environment, dedicated temporary/cache
|
||||
directories, temporary or in-memory SQLite fixtures, mocked frontend APIs and
|
||||
loopback WebSocket fixtures. No production configuration, credentials,
|
||||
databases, application server, or external support messages were used.
|
||||
- The service test harness rejects non-loopback HTTP requests before the
|
||||
underlying transport/DNS, including redirects. Concrete Slack, Telegram,
|
||||
Discord, Zalo and HTTP email clients were inspected to confirm their default
|
||||
transport usage. The only test WebSocket dialer targets its own loopback server.
|
||||
- Service fixtures explicitly replace the transitive AI-runtime hook with a
|
||||
test-only no-op. Dedicated hook tests still install their own spy; AI-runtime
|
||||
behavior is not claimed as covered by the service suite.
|
||||
- `TestHandleRequest*` email/SMTP cases were excluded from the expanded service
|
||||
run: raw SMTP does not use the HTTP guard. No unrestricted `go test ./...` was
|
||||
run. This exclusion is an isolation limit, not a passing result.
|
||||
- Independent source review of customer/issuer isolation, staff permissions,
|
||||
long-lived authorization, channel policy concurrency and session refresh.
|
||||
- Cross-review of editor draft isolation and customer chat lifecycle boundaries.
|
||||
- Setup preview/apply permission, transaction and target review.
|
||||
- Syntax parsing of changed TypeScript/TSX and JavaScript regression sources.
|
||||
- JSON/YAML parsing, matching new locale keys, and Git whitespace checks.
|
||||
|
||||
## Historical baseline completed checks
|
||||
These checks are not a replacement for compilation or executing the tests.
|
||||
|
||||
- `gofmt` on all changed Go files and `git diff --check`.
|
||||
- `go test ./internal/pkg/openidentity`: passed, including scoped issuer/channel,
|
||||
signed-policy and signature-boundary tests.
|
||||
- `GOMAXPROCS=2 go test -p 2 -race -tags dev ./internal/pkg/eventbus ./internal/pkg/openidentity`:
|
||||
passed.
|
||||
- Focused service security/setup/realtime suite with `-race -tags dev`: 46
|
||||
top-level tests, 102 successful test/subtest events, no failures or skips.
|
||||
Covers identity collisions/concurrent claims, live session revocation,
|
||||
permission boundaries, actual WebSocket pumps, channel CAS, preview/apply,
|
||||
rollback and dispatch outcomes, plus the transport guard and redirect check.
|
||||
- `GOMAXPROCS=2 go test -p 2 -tags dev ./internal/services -skip '^TestHandleRequest' -count=1`:
|
||||
passed, 217 top-level tests and 295 successful test/subtest events.
|
||||
- `GOMAXPROCS=2 go build -p 2 -tags dev -buildvcs=false ./...`: passed. The `dev`
|
||||
embedding mode is the repository CI test mode. VCS stamping was unavailable in
|
||||
the isolated worktree and was disabled only for this compile check.
|
||||
- `cd web && pnpm typecheck`: passed with declared locked dependencies before
|
||||
the later six-file lint cleanup. The post-cleanup retry was killed with exit
|
||||
137 without a TypeScript diagnostic; the final combined tree must rerun it.
|
||||
- All frontend Node test files under app/components/hooks/i18n/lib:
|
||||
226 tests passed, no failures or skips. This includes the SDK, draft revisions,
|
||||
account/customer-session isolation, setup contract, abortable realtime recovery,
|
||||
hidden/unmounted lifecycle and navigation checks.
|
||||
- Four additional offline component-lifecycle regressions passed after the
|
||||
six-file lint cleanup (bulk-move selection/stale responses, palette snapshots,
|
||||
notification cleanup, editor/origin initialization). The earlier 226-test full
|
||||
run predates these four additions.
|
||||
- Full `pnpm lint`: exit 0 after localized lifecycle corrections, with zero
|
||||
errors and 45 existing warnings. No lint rules were disabled.
|
||||
## Not yet validated
|
||||
|
||||
## Corrections found by execution
|
||||
- Go formatting and compilation: the Go toolchain was unavailable during this
|
||||
source-review pass. Run gofmt on all changed Go files before merging.
|
||||
- Automated regression tests: authored but not executed.
|
||||
- Full frontend typecheck, lint and build with declared dependencies: not run.
|
||||
A limited attempt using unrelated available dependencies was incomplete due
|
||||
to missing project packages; it is not a successful project typecheck.
|
||||
- Race detector, actual MySQL CAS/unique-index behavior, and staging additive
|
||||
schema upgrade/reconciliation: not run.
|
||||
- Application/browser acceptance on desktop and narrow screens: not run.
|
||||
- Production migration, service startup and deployment: not performed.
|
||||
|
||||
- The old realtime test fixture lacked required conversation counters. It now
|
||||
matches the DTO; the history/realtime merge assertion was retained unchanged.
|
||||
- Async assignment notifications could outlive their test database. The eventbus
|
||||
now exposes a drain for already-published handlers, and the fixture waits before
|
||||
restoring global state. Nested publishes/error callbacks have a regression
|
||||
test. No sleep or disabled race check is used.
|
||||
- Setup background responses are fenced by effect lifetime/request generation.
|
||||
The staff realtime hook captures its user ID in the effect rather than writing
|
||||
a ref during render. VM test harness variable names satisfy Next ESLint rules.
|
||||
- Four missing generic setup locale keys were supplied in English, Russian and
|
||||
Chinese. Existing locale-integrity assertions are unchanged.
|
||||
- Three stale source-test harnesses were aligned to current route groups and
|
||||
component ownership while retaining realtime placement, accessible label,
|
||||
trigger-style and collapsed-navigation checks.
|
||||
- Seven existing lint errors across six components were corrected without rule
|
||||
suppressions: browser-origin/palette hydration now uses external snapshots;
|
||||
bulk-move targets reset on dialog/base changes and obsolete directory results
|
||||
are ignored; initial notification requests cannot update after cleanup;
|
||||
fullscreen initialization and toolbar construction no longer need the flagged
|
||||
mount-state effect or mutable ref-bearing action list.
|
||||
## Checks required before merge or release
|
||||
|
||||
## Historical baseline remaining release gates
|
||||
Use the repository's declared Go and frontend package versions, isolated temporary
|
||||
data, and mocked/loopback external services. Inspect test initialization first;
|
||||
never point regression tests at a live database or real accounts.
|
||||
|
||||
- Full-project lint has 45 existing warnings. The final feature integration must
|
||||
rerun lint, typecheck and the complete Node suite after all source changes.
|
||||
- The canonical frontend export (`pnpm build`) did not complete. An initial
|
||||
cross-worktree node_modules symlink was replaced by a local offline frozen-lock
|
||||
installation. The subsequent default Turbopack build reached optimized
|
||||
compilation, then was killed with exit 137 and no source diagnostic while the
|
||||
shared environment had little free memory. This is not a successful build; a
|
||||
low-concurrency build on the final combined tree remains required. No build,
|
||||
typecheck or security checks were disabled to bypass this result.
|
||||
- Actual MySQL CAS/nullable-unique-index behavior and additive schema upgrade/
|
||||
identity reconciliation on an approved staging backup have not been run.
|
||||
- Browser acceptance at desktop/narrow widths in Russian, English and Chinese
|
||||
has not been performed for this integrated baseline.
|
||||
- Production migration, service startup and deployment have not been performed.
|
||||
1. Format all changed Go files and compile the relevant packages.
|
||||
2. Run openidentity and backend security/setup tests, including concurrent identity
|
||||
registration, the actual WebSocket pumps, channel CAS and transaction rollback.
|
||||
Then run the package suites and race checks where supported.
|
||||
3. Run the frontend typecheck, lint and build with installed locked dependencies.
|
||||
4. Run the focused Node tests for customer sessions/store/realtime, message drafts,
|
||||
account isolation, setup preview and dismissal navigation.
|
||||
5. Validate SQLite and MySQL schema upgrades and uniqueness/CAS behavior on backups.
|
||||
6. Exercise the modified UI in Russian, English and Chinese, including failures,
|
||||
account/conversation switches, pending sends and expired sessions.
|
||||
|
||||
SQLite results do not establish MySQL behavior. Automated tests do not establish
|
||||
visual acceptance or a safe production upgrade. Preserve the identity/token
|
||||
upgrade precautions in `CUSTOMER_IDENTITY_UPGRADE.md` and preview/apply contract
|
||||
in `SETUP_PREVIEW.md` before any rollout.
|
||||
|
||||
## Final combined source validation (2026-10-16)
|
||||
|
||||
The combined canonical checkout at `1ec72508cdda2dfe3b3eed3158f8ba930d76d260`
|
||||
includes security/realtime integration, knowledge localization, operator
|
||||
availability/calendar/presence, final locale changes and the two fixture-only
|
||||
corrections described below. These results are distinct from the historical
|
||||
baseline above and from the separately integrated CoverIt vendor tree.
|
||||
|
||||
The same sanitized toolchain, temporary SQLite fixtures and loopback-only test
|
||||
transport were used. No application server, production database, credentials,
|
||||
migration or external support message was used. Raw SMTP `TestHandleRequest*`
|
||||
cases remain excluded, and the service-suite AI hook remains a test-only no-op;
|
||||
neither scope is claimed as covered. No unrestricted `go test ./...` was run.
|
||||
|
||||
### Completed combined checks
|
||||
|
||||
- All changed Go files are `gofmt`-clean; `git diff --check` passed.
|
||||
- `GOMAXPROCS=2 go test -p 2 -tags dev ./internal/services -skip '^TestHandleRequest' -count=1 -timeout=5m -json`:
|
||||
passed, 240 top-level tests / 333 test-and-subtest events, no failures or skips.
|
||||
The same audited scope with `-race` passed with identical counts and no race
|
||||
report (181.272 seconds).
|
||||
- Full race suites for `internal/pkg/eventbus`, `internal/pkg/openidentity` and
|
||||
`internal/pkg/i18nx`: 48 top-level tests / 78 successful events.
|
||||
- Focused dashboard availability permission/ACL race tests: 4 tests passed.
|
||||
- Focused knowledge handler, builder and RAG localization race tests: 12
|
||||
top-level tests / 18 successful events, with mocked provider callbacks and no
|
||||
external application calls.
|
||||
- `GOMAXPROCS=2 go build -p 2 -tags dev -buildvcs=false ./...`: passed. This is a
|
||||
complete source compile in the repository's test embedding mode, not a binary
|
||||
containing the production web export. VCS stamping was disabled only for the
|
||||
local compile environment.
|
||||
- `NODE_OPTIONS=--max-old-space-size=1024 pnpm typecheck`: passed.
|
||||
- Every frontend `*.test.mjs` under app/components/hooks/i18n/lib, run with
|
||||
`node --test --test-concurrency=1`: 262 tests in 14 suites passed; zero failures,
|
||||
cancellations or skips.
|
||||
- Full `pnpm lint`: exit 0, zero errors and 42 warnings. No rules were disabled.
|
||||
|
||||
The first combined service run identified two obsolete fixture assumptions,
|
||||
not production-code failures. The realtime operator fixture now reuses the
|
||||
existing `conversation.view` permission without changing its state. Only the
|
||||
readiness success scenario that expects an eligible human now seeds an employee,
|
||||
current permission, login session and bounded live-presence lease. Negative
|
||||
permission scenarios and all original behavior assertions were retained. The
|
||||
full normal and race results above were obtained after these fixture repairs.
|
||||
|
||||
### Asset assembly and packaging limit
|
||||
|
||||
- Both frontend projects used unchanged frozen lockfiles with dependency
|
||||
lifecycle scripts disabled. Web dependencies totalled 978 locked packages;
|
||||
Flowgram dependencies totalled 953. Official Go/module integrity checks passed.
|
||||
- Flowgram's production `pnpm build` passed and generated its complete public
|
||||
assets. Third-party parser warnings were reported, without build errors.
|
||||
- The SDK's official `pnpm build:sdk` ran twice; both outputs were deterministic
|
||||
and byte-identical to the tracked bundle. No generated SDK change is required.
|
||||
- The default Next 16.2.11 Turbopack production build did not complete: optimized
|
||||
compilation ended with exit 137 (`Killed`), without a source diagnostic.
|
||||
- A sequential supported `next build --webpack` attempt, with a 1 GiB Node heap
|
||||
and bounded workers, ended with a build-worker `SIGKILL`, also without a source
|
||||
diagnostic. This was not a successful alternate export.
|
||||
- One distinct experiment used the [documented memory optimization](https://nextjs.org/docs/app/guides/memory-usage)
|
||||
`experimental.webpackMemoryOptimizations` option and a 640 MiB Node heap. It
|
||||
ended with an explicit JavaScript heap-out-of-memory error and worker
|
||||
`SIGABRT`. The temporary config was restored byte-for-byte afterward. No build
|
||||
check, typecheck, security setting, font or application asset was bypassed or
|
||||
mocked. Official Google font CSS and a real font asset were reachable through
|
||||
the existing trusted proxy configuration.
|
||||
- Dependency manifest and lockfile hashes, and the tracked SDK hash, remained
|
||||
unchanged by assembly; no production source/config edit was retained. No native production-embedded binary was
|
||||
assembled because a complete Next export was not available. Flowgram/SDK
|
||||
success and the `-tags dev` Go compile do not satisfy that packaging gate.
|
||||
|
||||
The default frontend export and complete native assembly therefore still need
|
||||
a suitably provisioned isolated build environment. These failures do not prove
|
||||
a source defect, and the passed tests do not establish a deployable release.
|
||||
Actual MySQL behavior, approved staging upgrade/identity reconciliation, real
|
||||
browser desktop/narrow-width acceptance in Russian/English/Chinese and production
|
||||
migration/startup/deployment remain unverified. Preserve all upgrade precautions
|
||||
above. No cross-platform or LanceDB release assembly was attempted.
|
||||
Do not treat an unexecuted regression test as proof that a defect is fixed.
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# Knowledge retrieval localization and derived data
|
||||
|
||||
## Locale and request lifetime
|
||||
|
||||
The dashboard debug-search, debug-answer, and index-build handlers pass their HTTP request context into RAG. `i18nx.WithLocale` adds the locale already selected by `i18nx.Locale` without dropping cancellation, deadlines, or parent values. `i18nx.LocaleFromContext` falls back to the configured `DefaultLocale` for existing callers and background jobs.
|
||||
|
||||
This does **not** change application-wide locale detection. In particular, `ResolveRequestLocale` continues to use the configured default rather than trusting request language headers. Callers with an explicitly selected locale can carry it through the context helper.
|
||||
|
||||
Built-in debug-answer system/user prompts, no-answer and model-error fallbacks, answer-status names, and context wrappers use the English, Russian, and Chinese catalogs. Configured custom prompts, model output, user questions, document bodies, and FAQ source answers are not translated or rewritten. Cancellation is not converted into a completed fallback answer.
|
||||
|
||||
## FAQ labels and existing index data
|
||||
|
||||
FAQ question, similar-question, and answer labels are application-owned. The source strings are separate inputs and are preserved without trimming or substring replacement. New FAQ index content uses the configured default locale, not whichever request locale an administrator supplies. This keeps index generation consistent for a deployment.
|
||||
|
||||
For live retrieval only, the stored FAQ chunk is compared byte-for-byte with a complete serialization of the current authoritative FAQ record:
|
||||
|
||||
- The exact frozen pre-localization Chinese format is recognized.
|
||||
- The generated format in each currently supported locale is recognized.
|
||||
- Only a complete match permits rendering the application-owned labels in the selected context locale. This also safely handles newlines or literal `问题:`, `相似问:`, and `回答:` strings inside source content, because they are never parsed as separators.
|
||||
- If the source has since changed, the chunk was edited manually, the stored format is unknown, or no exact match exists, the stored text remains unchanged. Document chunks never enter this FAQ conversion path.
|
||||
|
||||
Locale changes and reads do not update stored chunks, vectors, hashes, FAQ records, or historical retrieval logs. Previous answers and hit snippets are historical evidence and remain as recorded. Newly generated logs record the localized content actually used for that new request; they do not relabel old answers after the fact.
|
||||
|
||||
Unverifiable legacy or stale chunks may therefore still show old generated wording. Rebuilding the affected index from its current source through the existing authorized indexing action is the deliberate way to regenerate that derived data. A rebuild can change embeddings, scores, and retrieval behavior and should be validated against representative queries before a production rollout. This change contains no migration, automatic rebuild, or production data write.
|
||||
|
||||
## Failure responses
|
||||
|
||||
Immediate debug/index failures use stable localized summaries instead of exposing SQL, provider, or network diagnostics. Known catalog-backed application errors without arbitrary diagnostic arguments retain their message and code even when wrapped by a provider error. Unrecognized errors, raw code errors, and errors containing interpolation arguments are replaced with a generic localized operation error. Stored operational details are not returned by this boundary.
|
||||
|
||||
## Focused validation
|
||||
|
||||
Run the isolated tests without external model or vector service credentials:
|
||||
|
||||
```sh
|
||||
go test -tags dev ./internal/pkg/i18nx ./internal/ai/rag ./internal/handlers/dashboard
|
||||
```
|
||||
|
||||
Coverage includes locale fallback and isolation; request cancellation/deadlines; localized no-hit, model-error, empty-model and successful generation branches; byte-for-byte source preservation; exact legacy matching and stale-content preservation; localized context wrappers; and safe error responses in all three locales. The model-generation tests inject a local function and the cancellation tests return before database or provider access.
|
||||
|
||||
## API labels, index failures, and FAQ spreadsheets
|
||||
|
||||
Knowledge-base, document, FAQ, directory, and retrieval-history DTO builders accept an optional locale; omitted locales use the configured default for compatibility. Dashboard handlers supply their selected locale explicitly. Stable type/status/index codes remain unchanged. The UI maps those codes with shared catalog helpers rather than trusting legacy display-name strings. Unknown values use a localized unknown label.
|
||||
|
||||
Document and FAQ DTOs return a safe localized indexing-failure summary, not the stored provider or SQL diagnostic. The UI applies the same safe summary when connected to an older server. This change does not rewrite stored operational diagnostics or add a public raw-error endpoint.
|
||||
|
||||
FAQ spreadsheet templates and export headers use the selected locale. Import accepts Russian headings and both legacy Chinese and English aliases. Export preserves stored question, answer, similar-question, and note values; import preserves authored question, answer, note, and individual nonempty similar-question text, including surrounding whitespace. Newlines remain the spreadsheet format's delimiter between similar questions. Missing optional columns no longer copy the question column accidentally. No sample-data initializer or table-reset command is needed or run.
|
||||
|
||||
Additional isolated checks:
|
||||
|
||||
```sh
|
||||
go test -tags dev ./internal/builders ./internal/services -run 'TestKnowledgeBuilders|TestKnowledgeFAQExcel|TestImportKnowledgeFAQs|TestDeleteKnowledgeBase|TestBuildKnowledgeBaseModel' -count=1
|
||||
node --test web/lib/knowledge-i18n.test.mjs
|
||||
```
|
||||
|
||||
These are logic and contract checks, not production or browser acceptance. Before rollout, validate the real KB list, content dialog, spreadsheet import/export, debug search/answer, and history in Russian and English at desktop and narrow widths. Use isolated fixtures and do not run the destructive sample-data initializer.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Support availability UI
|
||||
|
||||
Modified in this independent fork; see `hardening/README.md`. Source-only feature,
|
||||
not a production deployment or a claim of browser acceptance.
|
||||
|
||||
## Interfaces
|
||||
|
||||
- System → Support availability: deployment-default IANA timezone and explicit
|
||||
team calendar mode. A team can keep dated shifts or opt into recurring weekly
|
||||
periods and date exceptions. Switching modes never deletes dated shifts.
|
||||
- Recurring periods support split shifts, overnight ends and `00:00`–`24:00`.
|
||||
A closed date sends no open periods; unsaved hidden period inputs are retained
|
||||
while toggling the date back to open. Draft preview calls the server evaluator.
|
||||
- Existing dated-shift screens explain that stored rows are inactive for teams
|
||||
using recurring mode. They do not imply both schedules are combined.
|
||||
- Header/workbench availability controls persist Ready, Busy, Break, Unavailable
|
||||
and a half-open vacation interval. The backend captures the return status when
|
||||
vacation is first scheduled. Logging in does not reset an active absence.
|
||||
- Vacation and preview inputs explicitly use the browser timezone, show exact
|
||||
UTC instants, reject nonexistent DST times, and expose a choice for repeated
|
||||
local times. Recurring calendar results use the configured timezone. Opaque
|
||||
legacy process timezone names render next-opening instants explicitly in UTC.
|
||||
- Admin profile rows show live presence, effective working status and server
|
||||
eligibility separately. Dedicated status changes are independent of general
|
||||
profile edits. The unused offline-message flag is retained but read-only and
|
||||
labeled as legacy, never as permission to assign to disconnected staff.
|
||||
- Transfer candidates come from the server's conversation-scoped manual
|
||||
eligibility endpoint, not a client-side `serviceStatus === Idle` guess.
|
||||
- Customer chat shows technical connection separately from aggregated human
|
||||
support availability. No employee identity, absence interval or private reason
|
||||
is sent by the public status endpoint. Scheduled opening copy does not promise
|
||||
that an operator will be connected.
|
||||
|
||||
## Safety and interaction coverage
|
||||
|
||||
The API adapter uses the existing request/session envelope and bounds background
|
||||
availability reads to 10 seconds. Components discard unmounted/old-session
|
||||
responses. Mutations and post-confirmation callbacks are checked against the
|
||||
captured authenticated session. Saves are single-flight; failed saves retain
|
||||
inputs. Read-only/preview Enter does not submit calendar changes. Closing a dirty
|
||||
form, changing scope, and internal/browser navigation warn before discarding.
|
||||
|
||||
The calendar is a scoped configuration form rather than a paginated resource, so
|
||||
it uses DashboardPage/Toolbar, react-hook-form, existing field/input/combobox and
|
||||
confirmation components. The existing agent CRUD/calendar components remain in
|
||||
place. All new copy is available in Russian, English and Chinese.
|
||||
|
||||
## Checks performed
|
||||
|
||||
- Full TypeScript `--noEmit --incremental false`: passed.
|
||||
- Focused availability helper/API tests: 13 passed.
|
||||
- Additional independent transport/locale edge tests: 10 passed, including
|
||||
independent deadlines, retry recovery, EU DST and Lord Howe 30-minute overlap.
|
||||
- Focused lifecycle tests: 6 passed, including StrictMode effect restart,
|
||||
duplicate saves, late session changes and confirmation after unmount.
|
||||
- Focused lint: no errors; existing agent avatar `<img>` warning remains.
|
||||
- Git whitespace check: passed.
|
||||
|
||||
The lifecycle tests execute actual component handlers in an isolated hook
|
||||
harness; they are not rendered-browser tests. Browser launch was blocked by the
|
||||
execution environment. Desktop/narrow-width rendering, focus behavior, keyboard
|
||||
interactions, native date/time controls, and real Next.js Back/Forward handling
|
||||
still require browser acceptance. No live account, customer message, production
|
||||
database or deployment was used. The integration owner records combined build
|
||||
and broader regression results separately in `hardening/VALIDATION.md`.
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package rag
|
||||
|
||||
import (
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/repositories"
|
||||
)
|
||||
|
||||
@@ -26,9 +24,6 @@ type answer struct {
|
||||
var Answer = &answer{}
|
||||
|
||||
func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchRequest) (*response.KnowledgeSearchResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Question) == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0340")
|
||||
}
|
||||
@@ -64,9 +59,6 @@ func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchReq
|
||||
}
|
||||
|
||||
func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerRequest, operator *dto.AuthPrincipal) (*response.KnowledgeAnswerResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Question) == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0340")
|
||||
}
|
||||
@@ -85,7 +77,7 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
|
||||
}
|
||||
retrieveMs := time.Since(retrieveStartedAt).Milliseconds()
|
||||
knowledgeBase := s.resolveAnswerKnowledgeBase(req.KnowledgeBaseIDs, results)
|
||||
contextResults := buildContextHits(Retrieve.SelectContextResultsWithContext(ctx, results, 4000))
|
||||
contextResults := buildContextHits(Retrieve.SelectContextResults(results, 4000))
|
||||
|
||||
hits := make([]response.KnowledgeSearchResult, 0, len(results))
|
||||
topScore := 0.0
|
||||
@@ -122,18 +114,36 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
|
||||
|
||||
fallbackMode := enums.AIAgentFallbackModeNoAnswer
|
||||
|
||||
answerStatus := enums.KnowledgeAnswerStatusNormal
|
||||
answer := ""
|
||||
modelName := ""
|
||||
promptTokens := 0
|
||||
completionTokens := 0
|
||||
generateStartedAt := time.Now()
|
||||
generated, err := generateKnowledgeAnswer(ctx, req.Question, results, answerMode, fallbackMode, ai.LLM.Chat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answerStatus := generated.Status
|
||||
answer := generated.Content
|
||||
modelName := generated.ModelName
|
||||
promptTokens := generated.PromptTokens
|
||||
completionTokens := generated.CompletionTokens
|
||||
generateMs := time.Since(generateStartedAt).Milliseconds()
|
||||
|
||||
if len(hits) == 0 {
|
||||
answerStatus = enums.KnowledgeAnswerStatusNoAnswer
|
||||
answer = buildFallbackAnswer(fallbackMode)
|
||||
} else {
|
||||
contextText := Retrieve.BuildContext(ctx, results, 4000)
|
||||
systemPrompt := buildAnswerSystemPrompt(answerMode)
|
||||
userPrompt := fmt.Sprintf("用户问题:%s\n\n参考资料:\n%s", req.Question, contextText)
|
||||
llmResult, llmErr := ai.LLM.Chat(ctx, systemPrompt, userPrompt)
|
||||
if llmErr != nil {
|
||||
answerStatus = enums.KnowledgeAnswerStatusFallback
|
||||
answer = buildFallbackAnswer(fallbackMode)
|
||||
} else {
|
||||
answer = llmResult.Content
|
||||
modelName = llmResult.ModelName
|
||||
promptTokens = llmResult.PromptTokens
|
||||
completionTokens = llmResult.CompletionTokens
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
answerStatus = enums.KnowledgeAnswerStatusFallback
|
||||
answer = buildFallbackAnswer(fallbackMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
generateMs := time.Since(generateStartedAt).Milliseconds()
|
||||
rerankLimit := 0
|
||||
chunkProvider := ""
|
||||
chunkTargetTokens := 0
|
||||
@@ -181,7 +191,7 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
|
||||
Question: req.Question,
|
||||
Answer: answer,
|
||||
AnswerStatus: int(answerStatus),
|
||||
AnswerStatusName: getAnswerStatusName(ctx, answerStatus),
|
||||
AnswerStatusName: getAnswerStatusName(answerStatus),
|
||||
Citations: citations,
|
||||
Hits: hits,
|
||||
HitCount: len(hits),
|
||||
@@ -366,20 +376,20 @@ func resolveDefaultRerankLimit(items []models.KnowledgeBase) int {
|
||||
return limit
|
||||
}
|
||||
|
||||
func buildAnswerSystemPrompt(ctx context.Context, answerMode enums.KnowledgeAnswerMode) string {
|
||||
key := "knowledge.rag.system.strict"
|
||||
func buildAnswerSystemPrompt(answerMode enums.KnowledgeAnswerMode) string {
|
||||
if answerMode == enums.KnowledgeAnswerModeAssist {
|
||||
key = "knowledge.rag.system.assist"
|
||||
return "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。"
|
||||
}
|
||||
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
|
||||
return "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。"
|
||||
}
|
||||
|
||||
func buildFallbackAnswer(ctx context.Context, fallbackMode enums.AIAgentFallbackMode) string {
|
||||
key := "knowledge.rag.fallback.noAnswer"
|
||||
if fallbackMode == enums.AIAgentFallbackModeSuggestRetry {
|
||||
key = "knowledge.rag.fallback.suggestRetry"
|
||||
func buildFallbackAnswer(fallbackMode enums.AIAgentFallbackMode) string {
|
||||
switch fallbackMode {
|
||||
case enums.AIAgentFallbackModeSuggestRetry:
|
||||
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
|
||||
default:
|
||||
return "当前知识库暂无明确信息。"
|
||||
}
|
||||
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
|
||||
}
|
||||
|
||||
func defaultRetrieveChannel(channel string) string {
|
||||
@@ -396,52 +406,17 @@ func defaultRetrieveScene(scene string) string {
|
||||
return scene
|
||||
}
|
||||
|
||||
func getAnswerStatusName(ctx context.Context, status enums.KnowledgeAnswerStatus) string {
|
||||
key := "knowledge.labels.unknown"
|
||||
func getAnswerStatusName(status enums.KnowledgeAnswerStatus) string {
|
||||
switch status {
|
||||
case enums.KnowledgeAnswerStatusNormal, enums.KnowledgeAnswerStatusNoAnswer, enums.KnowledgeAnswerStatusFallback, enums.KnowledgeAnswerStatusBlocked:
|
||||
key = fmt.Sprintf("knowledge.labels.answer.%d", status)
|
||||
case enums.KnowledgeAnswerStatusNormal:
|
||||
return "正常"
|
||||
case enums.KnowledgeAnswerStatusNoAnswer:
|
||||
return "无答案"
|
||||
case enums.KnowledgeAnswerStatusFallback:
|
||||
return "兜底"
|
||||
case enums.KnowledgeAnswerStatusBlocked:
|
||||
return "风控拦截"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
|
||||
}
|
||||
|
||||
type generatedKnowledgeAnswer struct {
|
||||
ai.ChatCompletionResult
|
||||
Status enums.KnowledgeAnswerStatus
|
||||
}
|
||||
|
||||
type knowledgeChatFunc func(context.Context, string, string) (*ai.ChatCompletionResult, error)
|
||||
|
||||
// Keep generation independent of persistence so fallback and cancellation paths
|
||||
// can be tested without a configured model, external service, or database.
|
||||
func generateKnowledgeAnswer(ctx context.Context, question string, results []RetrieveResult, mode enums.KnowledgeAnswerMode, fallbackMode enums.AIAgentFallbackMode, chat knowledgeChatFunc) (*generatedKnowledgeAnswer, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &generatedKnowledgeAnswer{Status: enums.KnowledgeAnswerStatusNoAnswer}
|
||||
result.Content = buildFallbackAnswer(ctx, fallbackMode)
|
||||
if len(results) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
contextText := Retrieve.BuildContext(ctx, results, 4000)
|
||||
systemPrompt := buildAnswerSystemPrompt(ctx, mode)
|
||||
userPrompt := i18nx.Getf(i18nx.LocaleFromContext(ctx), "knowledge.rag.userPrompt", question, contextText)
|
||||
llmResult, err := chat(ctx, systemPrompt, userPrompt)
|
||||
// An interrupted request is not a model failure and must not be logged as a
|
||||
// completed fallback answer after the client has gone away.
|
||||
if contextErr := ctx.Err(); contextErr != nil {
|
||||
return nil, contextErr
|
||||
}
|
||||
if err != nil || llmResult == nil {
|
||||
result.Status = enums.KnowledgeAnswerStatusFallback
|
||||
return result, nil
|
||||
}
|
||||
result.ChatCompletionResult = *llmResult
|
||||
if strings.TrimSpace(llmResult.Content) == "" {
|
||||
result.Status = enums.KnowledgeAnswerStatusFallback
|
||||
result.Content = buildFallbackAnswer(ctx, fallbackMode)
|
||||
return result, nil
|
||||
}
|
||||
result.Status = enums.KnowledgeAnswerStatusNormal
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
@@ -31,17 +27,17 @@ func TestBuildFallbackAnswer(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := buildFallbackAnswer(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), tt.mode); got != tt.expected {
|
||||
if got := buildFallbackAnswer(tt.mode); got != tt.expected {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAnswerStatusName(t *testing.T) {
|
||||
if got := getAnswerStatusName(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), enums.KnowledgeAnswerStatusNoAnswer); got != "无答案" {
|
||||
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusNoAnswer); got != "无答案" {
|
||||
t.Fatalf("expected no-answer label, got %q", got)
|
||||
}
|
||||
if got := getAnswerStatusName(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), enums.KnowledgeAnswerStatusFallback); got != i18nx.Getf(i18nx.LocaleZhCN, "knowledge.labels.answer.3") {
|
||||
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusFallback); got != "兜底" {
|
||||
t.Fatalf("expected fallback label, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
// faqContent keeps application-owned labels separate from source text. Never
|
||||
// translate, trim, or replace substrings in the source question or answer.
|
||||
type faqContent struct {
|
||||
question string
|
||||
similarQuestions []string
|
||||
answer string
|
||||
}
|
||||
|
||||
func sourceFAQContent(faq models.KnowledgeFAQ) faqContent {
|
||||
content := faqContent{question: faq.Question, answer: faq.Answer}
|
||||
if faq.SimilarQuestions != "" {
|
||||
_ = json.Unmarshal([]byte(faq.SimilarQuestions), &content.similarQuestions)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (content faqContent) render(locale string) string {
|
||||
parts := []string{i18nx.Getf(locale, "knowledge.rag.faq.question", content.question)}
|
||||
if len(content.similarQuestions) > 0 {
|
||||
similar := joinNonEmptySimilarQuestions(content.similarQuestions, i18nx.Getf(locale, "knowledge.rag.faq.separator"))
|
||||
parts = append(parts, i18nx.Getf(locale, "knowledge.rag.faq.similarQuestions", similar))
|
||||
}
|
||||
parts = append(parts, i18nx.Getf(locale, "knowledge.rag.faq.answer", content.answer))
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// Index generation deliberately uses the configured default, not the locale of
|
||||
// whichever administrator requested a build. A locale read never re-embeds data.
|
||||
func buildFAQChunkContent(faq models.KnowledgeFAQ) string {
|
||||
return sourceFAQContent(faq).render(i18nx.DefaultLocale)
|
||||
}
|
||||
|
||||
// legacyChinese renders the exact pre-localization serialization. This is a
|
||||
// recognition format, not UI copy. Keep it frozen when catalog wording changes.
|
||||
func (content faqContent) legacyChinese() string {
|
||||
parts := []string{fmt.Sprintf("问题:%s", content.question)}
|
||||
if len(content.similarQuestions) > 0 {
|
||||
parts = append(parts, "相似问:"+joinNonEmptySimilarQuestions(content.similarQuestions, ";"))
|
||||
}
|
||||
parts = append(parts, "回答:"+content.answer)
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// Only a full byte-for-byte match against a generated representation of the
|
||||
// current source proves which text belongs to us. Stale/manual/malformed chunks
|
||||
// stay as recorded. In particular, do not infer labels by splitting on Chinese
|
||||
// words: the source content may itself contain those words or arbitrary lines.
|
||||
// This function is for live retrieval only, never historical log replay.
|
||||
func renderVerifiedFAQChunkContent(ctx context.Context, stored string, faq models.KnowledgeFAQ) string {
|
||||
content := sourceFAQContent(faq)
|
||||
if stored == content.legacyChinese() {
|
||||
return content.render(i18nx.LocaleFromContext(ctx))
|
||||
}
|
||||
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
|
||||
if stored == content.render(locale) {
|
||||
return content.render(i18nx.LocaleFromContext(ctx))
|
||||
}
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
func joinNonEmptySimilarQuestions(items []string, separator string) string {
|
||||
nonEmpty := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != "" {
|
||||
nonEmpty = append(nonEmpty, item)
|
||||
}
|
||||
}
|
||||
return strings.Join(nonEmpty, separator)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -47,9 +47,6 @@ var Index = &index{
|
||||
}
|
||||
|
||||
func (s *index) IndexDocumentByID(ctx context.Context, documentID int64) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
document, err := s.loadDocumentByID(documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -58,9 +55,6 @@ func (s *index) IndexDocumentByID(ctx context.Context, documentID int64) error {
|
||||
}
|
||||
|
||||
func (s *index) IndexDocument(ctx context.Context, document models.KnowledgeDocument) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
if err := s.markDocumentIndexPending(document.ID); err != nil {
|
||||
slog.Error("Failed to mark knowledge document index as pending", "document_id", document.ID, "error", err)
|
||||
@@ -98,9 +92,6 @@ func (s *index) IndexDocument(ctx context.Context, document models.KnowledgeDocu
|
||||
}
|
||||
|
||||
func (s *index) IndexFAQByID(ctx context.Context, faqID int64) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
faq, err := s.loadFAQByID(faqID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -297,6 +288,43 @@ func (s *index) RebuildKnowledgeBaseIndex(ctx context.Context, knowledgeBaseID i
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFAQChunkContent(faq models.KnowledgeFAQ) string {
|
||||
parts := []string{fmt.Sprintf("问题:%s", faq.Question)}
|
||||
var similarQuestions []string
|
||||
if faq.SimilarQuestions != "" {
|
||||
_ = json.Unmarshal([]byte(faq.SimilarQuestions), &similarQuestions)
|
||||
}
|
||||
if len(similarQuestions) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("相似问:%s", joinSimilarQuestions(similarQuestions)))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("回答:%s", faq.Answer))
|
||||
content := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += part
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func joinSimilarQuestions(items []string) string {
|
||||
result := ""
|
||||
for _, item := range items {
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if result != "" {
|
||||
result += ";"
|
||||
}
|
||||
result += item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *index) resetKnowledgeBaseIndexStorage(ctx context.Context, knowledgeBaseID int64) error {
|
||||
chunks := repositories.KnowledgeChunkRepository.FindByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
|
||||
return s.cleanupKnowledgeBaseChunks(ctx, knowledgeBaseID, chunks)
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
)
|
||||
|
||||
@@ -18,7 +14,7 @@ func TestBuildFAQChunkContent(t *testing.T) {
|
||||
Answer: "在订单页发起退款。",
|
||||
}
|
||||
|
||||
content := sourceFAQContent(faq).render(i18nx.LocaleZhCN)
|
||||
content := buildFAQChunkContent(faq)
|
||||
if !strings.Contains(content, "问题:如何退款") {
|
||||
t.Fatalf("expected question in content, got %q", content)
|
||||
}
|
||||
@@ -74,7 +70,7 @@ func TestNormalizeContextResultsMergesAndDedupes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildContextChunkText(t *testing.T) {
|
||||
faqText := buildContextChunkText(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), RetrieveResult{
|
||||
faqText := buildContextChunkText(RetrieveResult{
|
||||
FaqID: 1,
|
||||
FaqQuestion: "如何退款",
|
||||
Content: "在订单页发起退款。",
|
||||
@@ -83,7 +79,7 @@ func TestBuildContextChunkText(t *testing.T) {
|
||||
t.Fatalf("unexpected faq context text: %q", faqText)
|
||||
}
|
||||
|
||||
docText := buildContextChunkText(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), RetrieveResult{
|
||||
docText := buildContextChunkText(RetrieveResult{
|
||||
DocumentID: 2,
|
||||
DocumentTitle: "退款文档",
|
||||
SectionPath: "售后/退款",
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/ai"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
func TestKnowledgeAnswerLocalizedFallbackPaths(t *testing.T) {
|
||||
tests := []struct{ locale, fallback, retry, noAnswerStatus string }{
|
||||
{i18nx.LocaleEnUS, "The knowledge base currently has no clear information.", "The knowledge base did not contain enough clear information. Try asking a more specific question.", "No answer"},
|
||||
{i18nx.LocaleRuRU, "В базе знаний пока нет однозначной информации.", "В базе знаний недостаточно однозначной информации. Попробуйте задать более конкретный вопрос.", "Нет ответа"},
|
||||
{i18nx.LocaleZhCN, "当前知识库暂无明确信息。", "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。", "无答案"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.locale, func(t *testing.T) {
|
||||
ctx := i18nx.WithLocale(context.Background(), tt.locale)
|
||||
called := false
|
||||
failedChat := func(context.Context, string, string) (*ai.ChatCompletionResult, error) {
|
||||
called = true
|
||||
return nil, errors.New("provider failed")
|
||||
}
|
||||
noHits, err := generateKnowledgeAnswer(ctx, "问题 не переводить", nil, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, failedChat)
|
||||
if err != nil || called || noHits.Status != enums.KnowledgeAnswerStatusNoAnswer || noHits.Content != tt.fallback {
|
||||
t.Fatalf("no-hit result = %#v, %v; model called = %v", noHits, err, called)
|
||||
}
|
||||
if got := getAnswerStatusName(ctx, noHits.Status); got != tt.noAnswerStatus {
|
||||
t.Fatalf("status = %q", got)
|
||||
}
|
||||
hits := []RetrieveResult{{DocumentID: 1, Content: "Reference content"}}
|
||||
failed, err := generateKnowledgeAnswer(ctx, "Q", hits, enums.KnowledgeAnswerModeAssist, enums.AIAgentFallbackModeNoAnswer, failedChat)
|
||||
if err != nil || !called || failed.Status != enums.KnowledgeAnswerStatusFallback || failed.Content != tt.fallback {
|
||||
t.Fatalf("failed model result = %#v, %v", failed, err)
|
||||
}
|
||||
for _, empty := range []*ai.ChatCompletionResult{nil, {}, {Content: " \n\t"}} {
|
||||
got, err := generateKnowledgeAnswer(ctx, "Q", hits, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeSuggestRetry, func(context.Context, string, string) (*ai.ChatCompletionResult, error) { return empty, nil })
|
||||
if err != nil || got.Status != enums.KnowledgeAnswerStatusFallback || got.Content != tt.retry {
|
||||
t.Fatalf("empty result fallback = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeGenerationPreservesUserAndModelContent(t *testing.T) {
|
||||
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
|
||||
ctx := i18nx.WithLocale(context.Background(), locale)
|
||||
question := " 问题:User's question\n回答:not a wrapper "
|
||||
body := " 回答:body\n相似问:literal user text\n "
|
||||
output := &ai.ChatCompletionResult{Content: " 模型回答\n", ModelName: "test-model", PromptTokens: 4, CompletionTokens: 2}
|
||||
got, err := generateKnowledgeAnswer(ctx, question, []RetrieveResult{{DocumentID: 1, DocumentTitle: "用户标题", Content: body}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(modelCtx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
|
||||
if i18nx.LocaleFromContext(modelCtx) != locale {
|
||||
t.Fatal("locale not forwarded to model")
|
||||
}
|
||||
if systemPrompt != i18nx.Getf(locale, "knowledge.rag.system.strict") || !strings.Contains(userPrompt, question) || !strings.Contains(userPrompt, body) {
|
||||
t.Fatalf("incorrect prompts: %q, %q", systemPrompt, userPrompt)
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
if err != nil || got.Status != enums.KnowledgeAnswerStatusNormal || got.ChatCompletionResult != *output {
|
||||
t.Fatalf("model output was changed: %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeCancellationBeforePersistenceOrProviderCalls(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU))
|
||||
cancel()
|
||||
if _, err := Answer.DebugSearch(ctx, request.KnowledgeSearchRequest{Question: "Q"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("debug search ignored cancellation: %v", err)
|
||||
}
|
||||
if _, err := Answer.DebugAnswer(ctx, request.KnowledgeAnswerRequest{Question: "Q"}, nil); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("debug answer ignored cancellation: %v", err)
|
||||
}
|
||||
if err := Index.IndexDocumentByID(ctx, 1); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("document build ignored cancellation: %v", err)
|
||||
}
|
||||
if err := Index.IndexFAQByID(ctx, 1); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("FAQ build ignored cancellation: %v", err)
|
||||
}
|
||||
if _, err := Retrieve.Retrieve(ctx, RetrieveRequest{Query: "Q"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("retrieval ignored cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeGenerationCancellationIsNotFallback(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU))
|
||||
defer cancel()
|
||||
got, err := generateKnowledgeAnswer(ctx, "Q", []RetrieveResult{{DocumentID: 1, Content: "source"}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(modelCtx context.Context, _, _ string) (*ai.ChatCompletionResult, error) {
|
||||
cancel()
|
||||
return nil, modelCtx.Err()
|
||||
})
|
||||
if got != nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled model should not become a saved fallback: %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFAQGeneratedWrappersPreserveSourceContent(t *testing.T) {
|
||||
faq := models.KnowledgeFAQ{
|
||||
Question: " 问题:What?\n回答:part of question ",
|
||||
SimilarQuestions: `["相似问:literal; text", "", " another\n问题:line "]`,
|
||||
Answer: " 回答:user answer\n相似问:inside answer\n问题:last line ",
|
||||
}
|
||||
original := faq
|
||||
source := sourceFAQContent(faq)
|
||||
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
|
||||
ctx := i18nx.WithLocale(context.Background(), locale)
|
||||
expected := source.render(locale)
|
||||
for _, stored := range []string{source.legacyChinese(), source.render(i18nx.LocaleEnUS), source.render(i18nx.LocaleRuRU), source.render(i18nx.LocaleZhCN)} {
|
||||
got := renderVerifiedFAQChunkContent(ctx, stored, faq)
|
||||
if got != expected || !strings.Contains(got, faq.Question) || !strings.Contains(got, faq.Answer) || !strings.Contains(got, " another\n问题:line ") {
|
||||
t.Fatalf("%s changed source text: %q", locale, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if faq != original {
|
||||
t.Fatal("source model mutated")
|
||||
}
|
||||
if got := buildFAQChunkContent(faq); got != source.render(i18nx.DefaultLocale) {
|
||||
t.Fatalf("index did not use configured locale: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFAQUnknownAndStaleDerivedContentStaysRecorded(t *testing.T) {
|
||||
faq := models.KnowledgeFAQ{Question: "Current Q", Answer: "Current A"}
|
||||
ctx := i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU)
|
||||
for _, stored := range []string{
|
||||
"问题:Old Q\n回答:Historical A",
|
||||
"问题:Current Q\n回答:Current A\n",
|
||||
"问题:Current Q\n回答:User-authored custom answer",
|
||||
"相似问:not a generated chunk",
|
||||
"{malformed:derived}",
|
||||
"",
|
||||
} {
|
||||
if got := renderVerifiedFAQChunkContent(ctx, stored, faq); got != stored {
|
||||
t.Fatalf("unverified stored content was rewritten: %q => %q", stored, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWrappersAreLocalizedWithoutChangingSource(t *testing.T) {
|
||||
content := " 原始文档\n回答:literal\n "
|
||||
tests := []struct{ locale, label string }{
|
||||
{i18nx.LocaleEnUS, "[Document: Document#7 | Section: 用户章节]"},
|
||||
{i18nx.LocaleRuRU, "[Документ: Документ №7 | Раздел: 用户章节]"},
|
||||
{i18nx.LocaleZhCN, "【文档:文档#7|章节:用户章节】"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ctx := i18nx.WithLocale(context.Background(), tt.locale)
|
||||
got := Retrieve.BuildContext(ctx, []RetrieveResult{{DocumentID: 7, SectionPath: "用户章节", Content: content}}, 4000)
|
||||
if got != tt.label+"\n"+content+"\n\n" {
|
||||
t.Fatalf("%s unexpected context: %q", tt.locale, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeGenerationDefaultLocaleAndEmptyResponseMetrics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
noHits, err := generateKnowledgeAnswer(ctx, "Q", nil, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, nil)
|
||||
if err != nil || noHits.Content != i18nx.Get("knowledge.rag.fallback.noAnswer") {
|
||||
t.Fatalf("default-locale caller changed behavior: %#v, %v", noHits, err)
|
||||
}
|
||||
empty := &ai.ChatCompletionResult{Content: " \n", ModelName: "local-test-model", PromptTokens: 12, CompletionTokens: 3}
|
||||
got, err := generateKnowledgeAnswer(ctx, "Q", []RetrieveResult{{DocumentID: 1, Content: "reference"}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(context.Context, string, string) (*ai.ChatCompletionResult, error) { return empty, nil })
|
||||
if err != nil || got.Status != enums.KnowledgeAnswerStatusFallback || got.Content != noHits.Content || got.ModelName != empty.ModelName || got.PromptTokens != empty.PromptTokens || got.CompletionTokens != empty.CompletionTokens {
|
||||
t.Fatalf("empty completion must retain usage metrics: %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package rag
|
||||
|
||||
import (
|
||||
@@ -33,9 +32,6 @@ type RetrieveTrace struct {
|
||||
|
||||
func (s *retrieve) RetrieveWithTrace(ctx context.Context, req RetrieveRequest) ([]RetrieveResult, *RetrieveTrace, error) {
|
||||
trace := newRetrieveTrace()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, trace, err
|
||||
}
|
||||
retrievableKnowledgeBases, _, ok := s.prepareRetrievableKnowledgeBases(req, trace)
|
||||
if !ok {
|
||||
return nil, trace, nil
|
||||
@@ -51,7 +47,7 @@ func (s *retrieve) RetrieveWithTrace(ctx context.Context, req RetrieveRequest) (
|
||||
if len(searchResults) == 0 {
|
||||
return nil, trace, nil
|
||||
}
|
||||
results, hydrateMs := s.hydrateRetrieveResults(ctx, searchResults)
|
||||
results, hydrateMs := s.hydrateRetrieveResults(searchResults)
|
||||
trace.HydrateMs = hydrateMs
|
||||
|
||||
return results, trace, nil
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int) []RetrieveResult {
|
||||
return s.SelectContextResultsWithContext(context.Background(), results, maxTokens)
|
||||
}
|
||||
|
||||
func (s *retrieve) SelectContextResultsWithContext(ctx context.Context, results []RetrieveResult, maxTokens int) []RetrieveResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -27,7 +20,7 @@ func (s *retrieve) SelectContextResultsWithContext(ctx context.Context, results
|
||||
if documentUsage[item.DocumentID] >= 2 {
|
||||
continue
|
||||
}
|
||||
chunkText := buildContextChunkText(ctx, item)
|
||||
chunkText := buildContextChunkText(item)
|
||||
estimatedTokens := len(chunkText) / 2
|
||||
if totalTokens+estimatedTokens > maxTokens {
|
||||
break
|
||||
@@ -39,15 +32,15 @@ func (s *retrieve) SelectContextResultsWithContext(ctx context.Context, results
|
||||
return selected
|
||||
}
|
||||
|
||||
func (s *retrieve) BuildContext(ctx context.Context, results []RetrieveResult, maxTokens int) string {
|
||||
func (s *retrieve) BuildContext(_ context.Context, results []RetrieveResult, maxTokens int) string {
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedResults := s.SelectContextResultsWithContext(ctx, results, maxTokens)
|
||||
normalizedResults := s.SelectContextResults(results, maxTokens)
|
||||
var builder strings.Builder
|
||||
for _, r := range normalizedResults {
|
||||
builder.WriteString(buildContextChunkText(ctx, r))
|
||||
builder.WriteString(buildContextChunkText(r))
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
@@ -132,27 +125,26 @@ func buildSectionKey(item RetrieveResult) string {
|
||||
return fmt.Sprintf("%d|chunk:%d", item.DocumentID, item.ChunkNo)
|
||||
}
|
||||
|
||||
func buildContextChunkText(ctx context.Context, item RetrieveResult) string {
|
||||
locale := i18nx.LocaleFromContext(ctx)
|
||||
func buildContextChunkText(item RetrieveResult) string {
|
||||
if item.FaqID > 0 {
|
||||
title := strings.TrimSpace(item.FaqQuestion)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if title == "" {
|
||||
title = i18nx.Getf(locale, "knowledge.rag.context.faqTitle", item.FaqID)
|
||||
title = fmt.Sprintf("FAQ#%d", item.FaqID)
|
||||
}
|
||||
return i18nx.Getf(locale, "knowledge.rag.context.faq", title, item.Content)
|
||||
return fmt.Sprintf("【FAQ:%s】\n%s\n\n", title, item.Content)
|
||||
}
|
||||
title := strings.TrimSpace(item.DocumentTitle)
|
||||
if title == "" {
|
||||
title = i18nx.Getf(locale, "knowledge.rag.context.documentTitle", item.DocumentID)
|
||||
title = fmt.Sprintf("文档#%d", item.DocumentID)
|
||||
}
|
||||
if item.SectionPath != "" {
|
||||
return i18nx.Getf(locale, "knowledge.rag.context.documentSection", title, item.SectionPath, item.Content)
|
||||
return fmt.Sprintf("【文档:%s|章节:%s】\n%s\n\n", title, item.SectionPath, item.Content)
|
||||
}
|
||||
if item.Title != "" {
|
||||
return i18nx.Getf(locale, "knowledge.rag.context.documentHeading", title, item.Title, item.Content)
|
||||
return fmt.Sprintf("【文档:%s|标题:%s】\n%s\n\n", title, item.Title, item.Content)
|
||||
}
|
||||
return i18nx.Getf(locale, "knowledge.rag.context.document", title, item.Content)
|
||||
return fmt.Sprintf("【文档:%s】\n%s\n\n", title, item.Content)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package rag
|
||||
|
||||
import (
|
||||
@@ -73,7 +72,7 @@ func (s *retrieve) searchKnowledgeBaseVectors(ctx context.Context, req RetrieveR
|
||||
return searchResults, trace, nil
|
||||
}
|
||||
|
||||
func (s *retrieve) hydrateRetrieveResults(ctx context.Context, searchResults []vectordb.SearchResult) ([]RetrieveResult, int64) {
|
||||
func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult) ([]RetrieveResult, int64) {
|
||||
if len(searchResults) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
@@ -129,7 +128,6 @@ func (s *retrieve) hydrateRetrieveResults(ctx context.Context, searchResults []v
|
||||
|
||||
documentTitle := ""
|
||||
faqQuestion := ""
|
||||
content := chunk.Content
|
||||
if chunk.DocumentID > 0 {
|
||||
document := documentByID[chunk.DocumentID]
|
||||
if document == nil || document.Status != enums.StatusOk {
|
||||
@@ -143,7 +141,6 @@ func (s *retrieve) hydrateRetrieveResults(ctx context.Context, searchResults []v
|
||||
continue
|
||||
}
|
||||
faqQuestion = faq.Question
|
||||
content = renderVerifiedFAQChunkContent(ctx, chunk.Content, *faq)
|
||||
}
|
||||
|
||||
results = append(results, RetrieveResult{
|
||||
@@ -156,7 +153,7 @@ func (s *retrieve) hydrateRetrieveResults(ctx context.Context, searchResults []v
|
||||
ChunkNo: chunk.ChunkNo,
|
||||
Title: chunk.Title,
|
||||
SectionPath: chunk.SectionPath,
|
||||
Content: content,
|
||||
Content: chunk.Content,
|
||||
Score: sr.Score,
|
||||
ChunkType: extractChunkType(sr.Payload),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package bootstrap
|
||||
@@ -29,7 +28,6 @@ import (
|
||||
type publicRateLimits struct {
|
||||
login *ratelimit.Limiter
|
||||
supportRegister *ratelimit.Limiter
|
||||
supportStatus *ratelimit.Limiter
|
||||
sessionExchange *ratelimit.Limiter
|
||||
upload *ratelimit.Limiter
|
||||
docFeedback *ratelimit.Limiter
|
||||
@@ -43,7 +41,6 @@ type publicRateLimits struct {
|
||||
const (
|
||||
limitLogin = 20
|
||||
limitSupportRegister = 10
|
||||
limitSupportStatus = 120
|
||||
limitSessionExchange = 120
|
||||
limitUpload = 30
|
||||
limitDocFeedback = 20
|
||||
@@ -62,7 +59,6 @@ func newPublicRateLimits(cfg config.RateLimitConfig) publicRateLimits {
|
||||
return publicRateLimits{
|
||||
login: ratelimit.New(limitLogin, window),
|
||||
supportRegister: ratelimit.New(limitSupportRegister, window),
|
||||
supportStatus: ratelimit.New(limitSupportStatus, window),
|
||||
sessionExchange: ratelimit.New(limitSessionExchange, window),
|
||||
upload: ratelimit.New(limitUpload, window),
|
||||
docFeedback: ratelimit.New(limitDocFeedback, window),
|
||||
@@ -119,7 +115,6 @@ func registerApiMessageRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
|
||||
func registerApiSupportRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
group.GET("/config", api.SupportConfigGetConfig)
|
||||
group.GET("/status", middleware.RateLimit(limits.supportStatus), api.SupportGetStatus)
|
||||
group.POST("/auth/register", middleware.RateLimit(limits.supportRegister), api.SupportAuthPostRegister)
|
||||
group.GET("/me", api.SupportGetMe)
|
||||
group.Any("/doc-page/list", api.DocPageAnyList)
|
||||
@@ -530,13 +525,3 @@ func registerThirdEmailRoutes(group *gin.RouterGroup, limits publicRateLimits) {
|
||||
group.POST("/request", middleware.RateLimit(limits.emailRequest), third.EmailPostRequest)
|
||||
group.POST("/request/:channel_id", middleware.RateLimit(limits.emailRequest), third.EmailPostRequest)
|
||||
}
|
||||
|
||||
func registerDashboardSupportAvailabilityRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/me", dashboard.SupportAvailabilityGetMe)
|
||||
group.POST("/me", dashboard.SupportAvailabilityPostMe)
|
||||
group.GET("/agents", dashboard.SupportAvailabilityGetAgents)
|
||||
group.POST("/agent", dashboard.SupportAvailabilityPostAgent)
|
||||
group.GET("/calendar", dashboard.SupportAvailabilityGetCalendar)
|
||||
group.POST("/calendar", dashboard.SupportAvailabilityPostCalendar)
|
||||
group.POST("/calendar/preview", dashboard.SupportAvailabilityPostCalendarPreview)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
@@ -193,7 +192,6 @@ func addRouter(app *gin.Engine) {
|
||||
dashboardGroup := app.Group("/api/dashboard", middleware.AuthMiddleware)
|
||||
registerDashboardDashboardRoutes(dashboardGroup.Group("/dashboard"))
|
||||
registerDashboardSetupRoutes(dashboardGroup.Group("/setup"))
|
||||
registerDashboardSupportAvailabilityRoutes(dashboardGroup.Group("/support_availability"))
|
||||
registerDashboardUserRoutes(dashboardGroup.Group("/user"))
|
||||
registerDashboardOrganizationRoutes(dashboardGroup.Group("/organization"))
|
||||
registerDashboardCompanyRoutes(dashboardGroup.Group("/company"))
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package builders
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func BuildKnowledgeBase(item *models.KnowledgeBase, locales ...string) response.KnowledgeBaseResponse {
|
||||
func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseResponse {
|
||||
return response.KnowledgeBaseResponse{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
KnowledgeType: item.KnowledgeType,
|
||||
KnowledgeTypeName: knowledgeLabel(locales, "type", item.KnowledgeType),
|
||||
KnowledgeTypeName: enums.GetKnowledgeBaseTypeLabel(enums.KnowledgeBaseType(item.KnowledgeType)),
|
||||
Status: item.Status,
|
||||
StatusName: knowledgeLabel(locales, "status", item.Status),
|
||||
StatusName: enums.GetStatusLabel(item.Status),
|
||||
DefaultTopK: item.DefaultTopK,
|
||||
DefaultScoreThreshold: item.DefaultScoreThreshold,
|
||||
DefaultRerankLimit: item.DefaultRerankLimit,
|
||||
@@ -26,7 +24,7 @@ func BuildKnowledgeBase(item *models.KnowledgeBase, locales ...string) response.
|
||||
ChunkMaxTokens: item.ChunkMaxTokens,
|
||||
ChunkOverlapTokens: item.ChunkOverlapTokens,
|
||||
AnswerMode: item.AnswerMode,
|
||||
AnswerModeName: knowledgeLabel(locales, "mode", item.AnswerMode),
|
||||
AnswerModeName: enums.GetKnowledgeAnswerModeLabel(enums.KnowledgeAnswerMode(item.AnswerMode)),
|
||||
Remark: item.Remark,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
@@ -35,18 +33,18 @@ func BuildKnowledgeBase(item *models.KnowledgeBase, locales ...string) response.
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKnowledgeDocument(item *models.KnowledgeDocument, locales ...string) response.KnowledgeDocumentResponse {
|
||||
func BuildKnowledgeDocument(item *models.KnowledgeDocument) response.KnowledgeDocumentResponse {
|
||||
return response.KnowledgeDocumentResponse{
|
||||
ID: item.ID,
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
DirectoryID: item.DirectoryID,
|
||||
Title: item.Title,
|
||||
Status: item.Status,
|
||||
StatusName: knowledgeLabel(locales, "status", item.Status),
|
||||
StatusName: enums.GetStatusLabel(item.Status),
|
||||
IndexStatus: item.IndexStatus,
|
||||
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
|
||||
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
|
||||
IndexedAt: item.IndexedAt,
|
||||
IndexError: knowledgeIndexError(locales, item.IndexError),
|
||||
IndexError: item.IndexError,
|
||||
ContentHash: item.ContentHash,
|
||||
ContentType: item.ContentType,
|
||||
Content: item.Content,
|
||||
@@ -57,18 +55,18 @@ func BuildKnowledgeDocument(item *models.KnowledgeDocument, locales ...string) r
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKnowledgeDocumentList(item *models.KnowledgeDocument, locales ...string) response.KnowledgeDocumentListResponse {
|
||||
func BuildKnowledgeDocumentList(item *models.KnowledgeDocument) response.KnowledgeDocumentListResponse {
|
||||
return response.KnowledgeDocumentListResponse{
|
||||
ID: item.ID,
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
DirectoryID: item.DirectoryID,
|
||||
Title: item.Title,
|
||||
Status: item.Status,
|
||||
StatusName: knowledgeLabel(locales, "status", item.Status),
|
||||
StatusName: enums.GetStatusLabel(item.Status),
|
||||
IndexStatus: item.IndexStatus,
|
||||
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
|
||||
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
|
||||
IndexedAt: item.IndexedAt,
|
||||
IndexError: knowledgeIndexError(locales, item.IndexError),
|
||||
IndexError: item.IndexError,
|
||||
ContentHash: item.ContentHash,
|
||||
ContentType: item.ContentType,
|
||||
CreatedAt: item.CreatedAt,
|
||||
@@ -78,7 +76,7 @@ func BuildKnowledgeDocumentList(item *models.KnowledgeDocument, locales ...strin
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKnowledgeFAQ(item *models.KnowledgeFAQ, locales ...string) response.KnowledgeFAQResponse {
|
||||
func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse {
|
||||
return response.KnowledgeFAQResponse{
|
||||
ID: item.ID,
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
@@ -87,11 +85,11 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ, locales ...string) response.Kn
|
||||
Answer: item.Answer,
|
||||
SimilarQuestions: parseSimilarQuestions(item.SimilarQuestions),
|
||||
Status: item.Status,
|
||||
StatusName: knowledgeLabel(locales, "status", item.Status),
|
||||
StatusName: enums.GetStatusLabel(item.Status),
|
||||
IndexStatus: item.IndexStatus,
|
||||
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
|
||||
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
|
||||
IndexedAt: item.IndexedAt,
|
||||
IndexError: knowledgeIndexError(locales, item.IndexError),
|
||||
IndexError: item.IndexError,
|
||||
Remark: item.Remark,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
@@ -100,7 +98,7 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ, locales ...string) response.Kn
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKnowledgeDirectory(item *models.KnowledgeDirectory, locales ...string) response.KnowledgeDirectoryResponse {
|
||||
func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.KnowledgeDirectoryResponse {
|
||||
return response.KnowledgeDirectoryResponse{
|
||||
ID: item.ID,
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
@@ -108,7 +106,7 @@ func BuildKnowledgeDirectory(item *models.KnowledgeDirectory, locales ...string)
|
||||
Name: item.Name,
|
||||
SortNo: item.SortNo,
|
||||
Status: item.Status,
|
||||
StatusName: knowledgeLabel(locales, "status", item.Status),
|
||||
StatusName: enums.GetStatusLabel(item.Status),
|
||||
Remark: item.Remark,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
@@ -118,14 +116,14 @@ func BuildKnowledgeDirectory(item *models.KnowledgeDirectory, locales ...string)
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog, locales ...string) response.KnowledgeRetrieveLogResponse {
|
||||
func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.KnowledgeRetrieveLogResponse {
|
||||
return response.KnowledgeRetrieveLogResponse{
|
||||
ID: item.ID,
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
Channel: item.Channel,
|
||||
ChannelName: knowledgeLabel(locales, "channel", item.Channel),
|
||||
ChannelName: enums.GetKnowledgeRetrieveChannelLabel(enums.KnowledgeRetrieveChannel(item.Channel)),
|
||||
Scene: item.Scene,
|
||||
SceneName: knowledgeLabel(locales, "scene", item.Scene),
|
||||
SceneName: enums.GetKnowledgeRetrieveSceneLabel(enums.KnowledgeRetrieveScene(item.Scene)),
|
||||
SessionID: item.SessionID,
|
||||
ConversationID: item.ConversationID,
|
||||
RequestID: item.RequestID,
|
||||
@@ -133,7 +131,7 @@ func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog, locales ...str
|
||||
RewriteQuestion: item.RewriteQuestion,
|
||||
Answer: item.Answer,
|
||||
AnswerStatus: item.AnswerStatus,
|
||||
AnswerStatusName: knowledgeLabel(locales, "answer", item.AnswerStatus),
|
||||
AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(enums.KnowledgeAnswerStatus(item.AnswerStatus)),
|
||||
HitCount: item.HitCount,
|
||||
TopScore: item.TopScore,
|
||||
ChunkProvider: item.ChunkProvider,
|
||||
@@ -155,7 +153,7 @@ func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog, locales ...str
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit, locales ...string) response.KnowledgeRetrieveHitResponse {
|
||||
func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit) response.KnowledgeRetrieveHitResponse {
|
||||
return response.KnowledgeRetrieveHitResponse{
|
||||
ID: item.ID,
|
||||
RetrieveLogID: item.RetrieveLogID,
|
||||
@@ -169,7 +167,7 @@ func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit, locale
|
||||
Title: item.Title,
|
||||
SectionPath: item.SectionPath,
|
||||
ChunkType: item.ChunkType,
|
||||
ChunkTypeName: knowledgeLabel(locales, "chunk", item.ChunkType),
|
||||
ChunkTypeName: enums.GetKnowledgeChunkTypeLabel(enums.KnowledgeChunkType(item.ChunkType)),
|
||||
Provider: item.Provider,
|
||||
RankNo: item.RankNo,
|
||||
Score: item.Score,
|
||||
@@ -191,41 +189,3 @@ func parseSimilarQuestions(raw string) []string {
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
var knowledgeLabelCodes = map[string]map[string]bool{
|
||||
"type": {"document": true, "faq": true},
|
||||
"status": {"0": true, "1": true, "2": true},
|
||||
"index": {"pending": true, "indexed": true, "failed": true},
|
||||
"mode": {"1": true, "2": true},
|
||||
"channel": {"im": true, "agent_assist": true, "api": true, "debug": true},
|
||||
"scene": {"first_response": true, "assist": true, "qa": true},
|
||||
"answer": {"1": true, "2": true, "3": true, "4": true},
|
||||
"chunk": {"text": true, "faq": true, "table": true, "code": true},
|
||||
}
|
||||
|
||||
// Labels are application-owned; titles, content, snippets and historical answers are not.
|
||||
func knowledgeLabel(locales []string, group string, value any) string {
|
||||
code := fmt.Sprint(value)
|
||||
|
||||
key := "knowledge.labels.unknown"
|
||||
if knowledgeLabelCodes[group][code] {
|
||||
key = "knowledge.labels." + group + "." + code
|
||||
}
|
||||
return i18nx.Getf(knowledgeLocale(locales), key)
|
||||
}
|
||||
|
||||
func knowledgeLocale(locales []string) string {
|
||||
if len(locales) > 0 {
|
||||
return i18nx.NormalizeLocale(locales[0])
|
||||
}
|
||||
return i18nx.DefaultLocale
|
||||
}
|
||||
|
||||
// Legacy index_error values can contain SQL, credentials or provider response bodies.
|
||||
// Keep those values out of the public DTO; this does not rewrite persisted diagnostics.
|
||||
func knowledgeIndexError(locales []string, stored string) string {
|
||||
if stored == "" {
|
||||
return ""
|
||||
}
|
||||
return i18nx.Getf(knowledgeLocale(locales), "error.knowledge.indexFailed")
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKnowledgeBuildersLocalizeLabelsWithoutChangingContent(t *testing.T) {
|
||||
for _, locale := range []string{"en-US", "ru-RU", "zh-CN"} {
|
||||
t.Run(locale, func(t *testing.T) {
|
||||
kb := BuildKnowledgeBase(&models.KnowledgeBase{Name: "用户 название", KnowledgeType: "faq", Status: enums.StatusOk, AnswerMode: 1}, locale)
|
||||
if kb.Name != "用户 название" || kb.KnowledgeTypeName != i18nx.Getf(locale, "knowledge.labels.type.faq") || kb.StatusName != i18nx.Getf(locale, "knowledge.labels.status.0") {
|
||||
t.Fatalf("unexpected labels/content: %+v", kb)
|
||||
}
|
||||
raw := " 原文 answer\n回答: keep this "
|
||||
faq := BuildKnowledgeFAQ(&models.KnowledgeFAQ{Question: raw, Answer: raw, Status: enums.StatusDisabled, IndexStatus: enums.KnowledgeDocumentIndexStatusFailed, IndexError: "SQL failed password=fixture-only"}, locale)
|
||||
if faq.Question != raw || faq.Answer != raw {
|
||||
t.Fatal("authored content changed")
|
||||
}
|
||||
if faq.IndexStatusName != i18nx.Getf(locale, "knowledge.labels.index.failed") || faq.StatusName != i18nx.Getf(locale, "knowledge.labels.status.1") {
|
||||
t.Fatalf("unexpected labels: %+v", faq)
|
||||
}
|
||||
if faq.IndexError != i18nx.Getf(locale, "error.knowledge.indexFailed") || strings.Contains(faq.IndexError, "fixture-only") {
|
||||
t.Fatalf("unsafe index error %q", faq.IndexError)
|
||||
}
|
||||
doc := BuildKnowledgeDocument(&models.KnowledgeDocument{Content: raw, IndexError: "provider raw error"}, locale)
|
||||
list := BuildKnowledgeDocumentList(&models.KnowledgeDocument{IndexError: "provider raw error"}, locale)
|
||||
if doc.Content != raw || doc.IndexError != faq.IndexError || list.IndexError != faq.IndexError {
|
||||
t.Fatal("document content or safe error mismatch")
|
||||
}
|
||||
log := BuildKnowledgeRetrieveLog(&models.KnowledgeRetrieveLog{Answer: raw, Question: raw, AnswerStatus: 2, TraceData: raw}, locale)
|
||||
hit := BuildKnowledgeRetrieveHitResponse(&models.KnowledgeRetrieveHit{Snippet: raw, ChunkType: "faq"}, locale)
|
||||
if log.Answer != raw || log.Question != raw || log.TraceData != raw || hit.Snippet != raw {
|
||||
t.Fatal("historical data was rewritten")
|
||||
}
|
||||
if log.AnswerStatusName != i18nx.Getf(locale, "knowledge.labels.answer.2") {
|
||||
t.Fatalf("answer label = %q", log.AnswerStatusName)
|
||||
}
|
||||
})
|
||||
}
|
||||
if BuildKnowledgeFAQ(&models.KnowledgeFAQ{}, "en-US").IndexError != "" {
|
||||
t.Fatal("empty error should remain empty")
|
||||
}
|
||||
}
|
||||
@@ -45,20 +45,12 @@ func CustomerPostSession_exchange(ctx *gin.Context) {
|
||||
// unsigned session exchange it never creates or changes an anonymous identity.
|
||||
func CustomerPostSession_refresh(ctx *gin.Context) {
|
||||
channel := services.ChannelService.GetEnabledChannel(ctx)
|
||||
if channel == nil {
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209"))
|
||||
return
|
||||
}
|
||||
if channel == nil { httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209")); return }
|
||||
result, err := services.CustomerSessionService.VerifyRequest(ctx, channel)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if err != nil { httpx.WriteJSON(ctx, err); return }
|
||||
services.CustomerSessionService.SetRefreshHeaders(ctx, result)
|
||||
source := "guest:"
|
||||
if result.ExternalUser.ExternalSource == enums.ExternalSourceUser {
|
||||
source = "user:"
|
||||
}
|
||||
if result.ExternalUser.ExternalSource == enums.ExternalSourceUser { source = "user:" }
|
||||
httpx.WriteJSON(ctx, &response.CustomerSessionExchangeResponse{
|
||||
CustomerSessionToken: result.Token,
|
||||
ExpiresAt: result.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SupportGetStatus(ctx *gin.Context) {
|
||||
channel := services.ChannelService.GetEnabledChannel(ctx)
|
||||
if channel == nil {
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209"))
|
||||
return
|
||||
}
|
||||
result, err := services.SupportAvailabilityService.PublicStatus(channel)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
httpx.WriteJSON(ctx, result)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
@@ -19,18 +18,13 @@ func AgentAnyList(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
cnd := params.NewPagedSqlCnd(ctx,
|
||||
list, paging := services.AgentProfileService.FindPageByCnd(params.NewPagedSqlCnd(ctx,
|
||||
params.QueryFilter{ParamName: "userId"},
|
||||
params.QueryFilter{ParamName: "teamId"},
|
||||
params.QueryFilter{ParamName: "serviceStatus"},
|
||||
params.QueryFilter{ParamName: "agentCode", Op: params.Like},
|
||||
params.QueryFilter{ParamName: "displayName", Op: params.Like},
|
||||
).Desc("id")
|
||||
status, _ := params.Get(ctx, "serviceStatus")
|
||||
if err := services.AgentProfileService.FilterEffectiveServiceStatus(cnd, status); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
list, paging := services.AgentProfileService.FindPageByCnd(cnd)
|
||||
).Desc("id"))
|
||||
results := builders.BuildAgentProfileList(list)
|
||||
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
|
||||
}
|
||||
@@ -40,17 +34,12 @@ func AgentGetList_all(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
cnd := params.NewPagedSqlCnd(ctx,
|
||||
list := services.AgentProfileService.Find(params.NewPagedSqlCnd(ctx,
|
||||
params.QueryFilter{ParamName: "userId"},
|
||||
params.QueryFilter{ParamName: "teamId"},
|
||||
params.QueryFilter{ParamName: "serviceStatus"},
|
||||
params.QueryFilter{ParamName: "agentCode", Op: params.Like},
|
||||
).Desc("id")
|
||||
status, _ := params.Get(ctx, "serviceStatus")
|
||||
if err := services.AgentProfileService.FilterEffectiveServiceStatus(cnd, status); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
list := services.AgentProfileService.Find(cnd)
|
||||
).Desc("id"))
|
||||
|
||||
httpx.WriteJSON(ctx, builders.BuildAgentProfileList(list))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
@@ -37,7 +35,7 @@ func KnowledgeBaseAnyList(ctx *gin.Context) {
|
||||
for _, item := range list {
|
||||
docCount := repositories.KnowledgeDocumentRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
|
||||
faqCount := repositories.KnowledgeFAQRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
|
||||
resp := builders.BuildKnowledgeBase(&item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeBase(&item)
|
||||
resp.DocumentCount = docCount
|
||||
resp.FAQCount = faqCount
|
||||
results = append(results, resp)
|
||||
@@ -56,7 +54,7 @@ func KnowledgeBaseAnyList_all(ctx *gin.Context) {
|
||||
).Asc("sort_no").Desc("id"))
|
||||
results := make([]response.KnowledgeBaseResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
resp := builders.BuildKnowledgeBase(&item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeBase(&item)
|
||||
results = append(results, resp)
|
||||
}
|
||||
httpx.WriteJSON(ctx, results)
|
||||
@@ -77,7 +75,7 @@ func KnowledgeBaseGetBy(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0283"))
|
||||
return
|
||||
}
|
||||
resp := builders.BuildKnowledgeBase(item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeBase(item)
|
||||
resp.DocumentCount = repositories.KnowledgeDocumentRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
|
||||
resp.FAQCount = repositories.KnowledgeFAQRepository.CountByKnowledgeBaseID(sqls.DB(), item.ID)
|
||||
httpx.WriteJSON(ctx, resp)
|
||||
@@ -100,7 +98,7 @@ func KnowledgeBasePostCreate(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildKnowledgeBase(item, i18nx.Locale(ctx)))
|
||||
httpx.WriteJSON(ctx, builders.BuildKnowledgeBase(item))
|
||||
}
|
||||
|
||||
func KnowledgeBasePostUpdate(ctx *gin.Context) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,7 +25,7 @@ func KnowledgeDirectoryGetList_all(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
list := services.KnowledgeDirectoryService.FindAllByKnowledgeBaseID(knowledgeBaseID)
|
||||
httpx.WriteJSON(ctx, buildKnowledgeDirectoryTreeResponses(list, i18nx.Locale(ctx)))
|
||||
httpx.WriteJSON(ctx, buildKnowledgeDirectoryTreeResponses(list))
|
||||
}
|
||||
|
||||
func KnowledgeDirectoryPostCreate(ctx *gin.Context) {
|
||||
@@ -46,7 +44,7 @@ func KnowledgeDirectoryPostCreate(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
resp := builders.BuildKnowledgeDirectory(item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeDirectory(item)
|
||||
httpx.WriteJSON(ctx, &resp)
|
||||
}
|
||||
|
||||
@@ -106,7 +104,7 @@ func KnowledgeDirectoryPostUpdate_sort(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
}
|
||||
|
||||
func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory, locales ...string) []response.KnowledgeDirectoryResponse {
|
||||
func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory) []response.KnowledgeDirectoryResponse {
|
||||
childrenByParent := make(map[int64][]models.KnowledgeDirectory, len(list))
|
||||
for _, item := range list {
|
||||
childrenByParent[item.ParentID] = append(childrenByParent[item.ParentID], item)
|
||||
@@ -116,7 +114,7 @@ func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory, loca
|
||||
items := childrenByParent[parentID]
|
||||
ret := make([]response.KnowledgeDirectoryResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
resp := builders.BuildKnowledgeDirectory(&item, knowledgeDirectoryLocale(locales))
|
||||
resp := builders.BuildKnowledgeDirectory(&item)
|
||||
resp.Children = build(item.ID)
|
||||
ret = append(ret, resp)
|
||||
}
|
||||
@@ -124,10 +122,3 @@ func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory, loca
|
||||
}
|
||||
return build(0)
|
||||
}
|
||||
|
||||
func knowledgeDirectoryLocale(locales []string) string {
|
||||
if len(locales) > 0 {
|
||||
return locales[0]
|
||||
}
|
||||
return i18nx.DefaultLocale
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
@@ -8,7 +7,6 @@ import (
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
@@ -49,7 +47,7 @@ func KnowledgeDocumentAnyList(ctx *gin.Context) {
|
||||
directoryPaths := services.KnowledgeDirectoryService.PathMap(knowledgeBaseID)
|
||||
results := make([]response.KnowledgeDocumentListResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
resp := builders.BuildKnowledgeDocumentList(&item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeDocumentList(&item)
|
||||
fillKnowledgeDocumentDirectory(&resp, directoryPaths)
|
||||
results = append(results, resp)
|
||||
}
|
||||
@@ -71,7 +69,7 @@ func KnowledgeDocumentGetBy(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0218"))
|
||||
return
|
||||
}
|
||||
resp := builders.BuildKnowledgeDocument(item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeDocument(item)
|
||||
fillKnowledgeDocumentDirectory(&resp, services.KnowledgeDirectoryService.PathMap(item.KnowledgeBaseID))
|
||||
httpx.WriteJSON(ctx, resp)
|
||||
}
|
||||
@@ -104,7 +102,7 @@ func KnowledgeDocumentPostCreate(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildKnowledgeDocument(item, i18nx.Locale(ctx)))
|
||||
httpx.WriteJSON(ctx, builders.BuildKnowledgeDocument(item))
|
||||
}
|
||||
|
||||
func KnowledgeDocumentPostUpdate(ctx *gin.Context) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
@@ -24,7 +23,7 @@ func KnowledgeFAQGetImport_template(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
file, err := services.KnowledgeFAQService.BuildKnowledgeFAQImportTemplate(i18nx.Locale(ctx))
|
||||
file, err := services.KnowledgeFAQService.BuildKnowledgeFAQImportTemplate()
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
@@ -42,7 +41,7 @@ func KnowledgeFAQGetExport(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0283"))
|
||||
return
|
||||
}
|
||||
file, err := services.KnowledgeFAQService.ExportKnowledgeFAQs(knowledgeBaseID, i18nx.Locale(ctx))
|
||||
file, err := services.KnowledgeFAQService.ExportKnowledgeFAQs(knowledgeBaseID)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
@@ -112,7 +111,7 @@ func KnowledgeFAQAnyList(ctx *gin.Context) {
|
||||
directoryPaths := services.KnowledgeDirectoryService.PathMap(knowledgeBaseID)
|
||||
results := make([]response.KnowledgeFAQResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
resp := builders.BuildKnowledgeFAQ(&item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeFAQ(&item)
|
||||
fillKnowledgeFAQDirectory(&resp, directoryPaths)
|
||||
results = append(results, resp)
|
||||
}
|
||||
@@ -134,7 +133,7 @@ func KnowledgeFAQGetBy(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0025"))
|
||||
return
|
||||
}
|
||||
resp := builders.BuildKnowledgeFAQ(item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeFAQ(item)
|
||||
fillKnowledgeFAQDirectory(&resp, services.KnowledgeDirectoryService.PathMap(item.KnowledgeBaseID))
|
||||
httpx.WriteJSON(ctx, resp)
|
||||
}
|
||||
@@ -155,7 +154,7 @@ func KnowledgeFAQPostCreate(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildKnowledgeFAQ(item, i18nx.Locale(ctx)))
|
||||
httpx.WriteJSON(ctx, builders.BuildKnowledgeFAQ(item))
|
||||
}
|
||||
|
||||
func KnowledgeFAQPostUpdate(ctx *gin.Context) {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"agent-desk/internal/ai/rag"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -29,9 +26,9 @@ func KnowledgeRetrievePostDebugSearch(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := rag.Answer.DebugSearch(knowledgeRequestContext(ctx), req)
|
||||
resp, err := rag.Answer.DebugSearch(context.Background(), req)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.retrieveFailed"))
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, resp)
|
||||
@@ -50,9 +47,9 @@ func KnowledgeRetrievePostDebugAnswer(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := rag.Answer.DebugAnswer(knowledgeRequestContext(ctx), req, operator)
|
||||
resp, err := rag.Answer.DebugAnswer(context.Background(), req, operator)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.retrieveFailed"))
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, resp)
|
||||
@@ -73,8 +70,8 @@ func KnowledgeRetrievePostBuild(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := rag.Answer.BuildDocumentIndex(knowledgeRequestContext(ctx), req.DocumentID); err != nil {
|
||||
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.indexFailed"))
|
||||
if err := rag.Answer.BuildDocumentIndex(context.Background(), req.DocumentID); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
@@ -86,8 +83,8 @@ func KnowledgeRetrievePostBuild(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := rag.Index.IndexFAQByID(knowledgeRequestContext(ctx), req.FAQID); err != nil {
|
||||
httpx.WriteJSON(ctx, safeKnowledgeOperationError(err, "error.knowledge.indexFailed"))
|
||||
if err := rag.Index.IndexFAQByID(context.Background(), req.FAQID); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
@@ -96,29 +93,3 @@ func KnowledgeRetrievePostBuild(ctx *gin.Context) {
|
||||
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0066"))
|
||||
}
|
||||
|
||||
func knowledgeRequestContext(ctx *gin.Context) context.Context {
|
||||
parent := context.Background()
|
||||
if ctx != nil && ctx.Request != nil {
|
||||
parent = ctx.Request.Context()
|
||||
}
|
||||
return i18nx.WithLocale(parent, i18nx.Locale(ctx))
|
||||
}
|
||||
|
||||
// Only catalog-owned errors without arbitrary diagnostic arguments are safe to
|
||||
// return from index/embedding/vector providers. Unwrap the application error so
|
||||
// a surrounding SQL/provider message never reaches the response.
|
||||
func safeKnowledgeOperationError(err error, fallbackKey string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var appErr *errorsx.I18nError
|
||||
if errors.As(err, &appErr) && appErr != nil && len(appErr.Args) == 0 && i18nx.Get(appErr.Key) != appErr.Key {
|
||||
return appErr
|
||||
}
|
||||
var localizedErr *i18nx.Error
|
||||
if errors.As(err, &localizedErr) && localizedErr != nil && len(localizedErr.Args) == 0 && i18nx.Get(localizedErr.Key) != localizedErr.Key {
|
||||
return i18nx.Errorf(localizedErr.Key)
|
||||
}
|
||||
return errorsx.BusinessErrorI18n(0, fallbackKey)
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
func TestKnowledgeRequestContextPreservesLocaleAndCancellation(t *testing.T) {
|
||||
parent, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/knowledge_retrieve/debug_answer", nil).WithContext(parent)
|
||||
ctx.Request.Header.Set("X-Locale", i18nx.LocaleZhCN)
|
||||
ctx.Request.Header.Set("Accept-Language", i18nx.LocaleZhCN)
|
||||
i18nx.SetLocale(ctx, i18nx.LocaleRuRU)
|
||||
derived := knowledgeRequestContext(ctx)
|
||||
if i18nx.LocaleFromContext(derived) != i18nx.LocaleRuRU {
|
||||
t.Fatal("request locale was lost")
|
||||
}
|
||||
parentDeadline, _ := parent.Deadline()
|
||||
if deadline, ok := derived.Deadline(); !ok || !deadline.Equal(parentDeadline) {
|
||||
t.Fatal("request deadline was lost")
|
||||
}
|
||||
cancel()
|
||||
if !errors.Is(derived.Err(), context.Canceled) {
|
||||
t.Fatal("request cancellation was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeOperationErrorsHideProviderDiagnostics(t *testing.T) {
|
||||
const diagnostic = "synthetic-private-diagnostic"
|
||||
for _, key := range []string{"error.knowledge.indexFailed", "error.knowledge.retrieveFailed"} {
|
||||
for _, cause := range []error{
|
||||
errors.New("SQL failed: " + diagnostic),
|
||||
fmt.Errorf("provider request failed: %w", errors.New(diagnostic)),
|
||||
web.NewError(2000, diagnostic),
|
||||
errorsx.BusinessErrorI18n(0, "error.e0340", diagnostic),
|
||||
errorsx.BusinessErrorI18n(0, "unknown.translation."+diagnostic),
|
||||
} {
|
||||
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
i18nx.SetLocale(ctx, locale)
|
||||
httpx.WriteJSON(ctx, safeKnowledgeOperationError(cause, key))
|
||||
body := recorder.Body.String()
|
||||
if strings.Contains(body, diagnostic) || !strings.Contains(body, i18nx.Getf(locale, key)) {
|
||||
t.Fatalf("unsafe/unlocalized %s response: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeOperationErrorPreservesCatalogApplicationError(t *testing.T) {
|
||||
original := errorsx.InvalidParamI18n("error.e0340")
|
||||
wrapped := fmt.Errorf("private provider details: %w", original)
|
||||
if got := safeKnowledgeOperationError(wrapped, "error.knowledge.retrieveFailed"); got != original {
|
||||
t.Fatalf("application error lost: %v", got)
|
||||
}
|
||||
localized := i18nx.Errorf("error.embeddingModel.noneEnabled")
|
||||
got := safeKnowledgeOperationError(fmt.Errorf("private: %w", localized), "error.knowledge.indexFailed")
|
||||
if got.Error() != localized.Error() || strings.Contains(got.Error(), "private") {
|
||||
t.Fatalf("localized application error changed/leaked: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
@@ -6,7 +5,6 @@ import (
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
@@ -41,7 +39,7 @@ func KnowledgeRetrieveLogAnyList(ctx *gin.Context) {
|
||||
list, paging := services.KnowledgeRetrieveLogService.FindPageByParams(queryParams)
|
||||
results := make([]response.KnowledgeRetrieveLogResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
resp := builders.BuildKnowledgeRetrieveLog(&item, i18nx.Locale(ctx))
|
||||
resp := builders.BuildKnowledgeRetrieveLog(&item)
|
||||
if knowledgeBase := services.KnowledgeBaseService.Get(item.KnowledgeBaseID); knowledgeBase != nil {
|
||||
resp.KnowledgeBaseName = knowledgeBase.Name
|
||||
}
|
||||
@@ -66,7 +64,7 @@ func KnowledgeRetrieveLogGetBy(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
logResp := builders.BuildKnowledgeRetrieveLog(logItem, i18nx.Locale(ctx))
|
||||
logResp := builders.BuildKnowledgeRetrieveLog(logItem)
|
||||
if knowledgeBase := services.KnowledgeBaseService.Get(logItem.KnowledgeBaseID); knowledgeBase != nil {
|
||||
logResp.KnowledgeBaseName = knowledgeBase.Name
|
||||
}
|
||||
@@ -74,7 +72,7 @@ func KnowledgeRetrieveLogGetBy(ctx *gin.Context) {
|
||||
hits := services.KnowledgeRetrieveLogService.FindHitsByRetrieveLogID(id)
|
||||
hitResults := make([]response.KnowledgeRetrieveHitResponse, 0, len(hits))
|
||||
for _, item := range hits {
|
||||
hitResults = append(hitResults, builders.BuildKnowledgeRetrieveHitResponse(&item, i18nx.Locale(ctx)))
|
||||
hitResults = append(hitResults, builders.BuildKnowledgeRetrieveHitResponse(&item))
|
||||
}
|
||||
|
||||
httpx.WriteJSON(ctx, response.KnowledgeRetrieveLogDetailResponse{
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
"agent-desk/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func SupportAvailabilityGetMe(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
principal := services.AuthService.GetAuthPrincipal(ctx)
|
||||
if principal == nil {
|
||||
httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
|
||||
return
|
||||
}
|
||||
item, err := services.SupportAvailabilityService.ForUser(principal.UserID, true)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, item)
|
||||
}
|
||||
func SupportAvailabilityPostMe(ctx *gin.Context) {
|
||||
principal := services.AuthService.GetAuthPrincipal(ctx)
|
||||
if principal == nil {
|
||||
httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
|
||||
return
|
||||
}
|
||||
req := request.SupportAvailabilityRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
// Never accept a target from a self-service request.
|
||||
item, err := services.SupportAvailabilityService.Update(principal.UserID, req, principal)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, item)
|
||||
}
|
||||
func SupportAvailabilityGetAgents(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
principal := services.AuthService.GetAuthPrincipal(ctx)
|
||||
if principal == nil {
|
||||
httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
|
||||
return
|
||||
}
|
||||
if !slices.Contains(principal.Permissions, constants.PermissionAgentView.Code) && !slices.Contains(principal.Permissions, constants.PermissionConversationAssign.Code) && !slices.Contains(principal.Permissions, constants.PermissionConversationTransfer.Code) {
|
||||
httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.e0225"))
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseInt(ctx.Query("conversationId"), 10, 64)
|
||||
if id <= 0 && !slices.Contains(principal.Permissions, constants.PermissionAgentView.Code) {
|
||||
httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.e0225"))
|
||||
return
|
||||
}
|
||||
items, err := services.SupportAvailabilityService.List(id, principal)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, items)
|
||||
}
|
||||
func SupportAvailabilityPostAgent(ctx *gin.Context) {
|
||||
principal, err := services.AuthService.RequirePermission(ctx, constants.PermissionAgentUpdate)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
req := request.SupportAvailabilityRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
item, err := services.SupportAvailabilityService.Update(req.UserID, req, principal)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, item)
|
||||
}
|
||||
func supportCalendarPermission(ctx *gin.Context, teamID int64, write bool) (*dto.AuthPrincipal, error) {
|
||||
if teamID == 0 {
|
||||
if write {
|
||||
return services.AuthService.RequirePermission(ctx, constants.PermissionSupportConfigUpdate)
|
||||
}
|
||||
return services.AuthService.RequirePermission(ctx, constants.PermissionSupportConfigView)
|
||||
}
|
||||
if write {
|
||||
return services.AuthService.RequirePermission(ctx, constants.PermissionAgentTeamScheduleUpdate)
|
||||
}
|
||||
return services.AuthService.RequirePermission(ctx, constants.PermissionAgentTeamScheduleView)
|
||||
}
|
||||
func SupportAvailabilityGetCalendar(ctx *gin.Context) {
|
||||
teamID, err := strconv.ParseInt(ctx.DefaultQuery("teamId", "0"), 10, 64)
|
||||
if err != nil || teamID < 0 {
|
||||
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.supportAvailability.invalidCalendar"))
|
||||
return
|
||||
}
|
||||
if _, err := supportCalendarPermission(ctx, teamID, false); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
item, err := services.SupportCalendarService.Get(teamID)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.supportAvailability.persistence"))
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, item)
|
||||
}
|
||||
func SupportAvailabilityPostCalendar(ctx *gin.Context) {
|
||||
req := request.SupportCalendarRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if _, err := supportCalendarPermission(ctx, req.TeamID, true); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
item, err := services.SupportCalendarService.Save(req)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, item)
|
||||
}
|
||||
func SupportAvailabilityPostCalendarPreview(ctx *gin.Context) {
|
||||
req := request.SupportCalendarPreviewRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if _, err := supportCalendarPermission(ctx, req.Calendar.TeamID, false); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
item, err := services.SupportCalendarService.Preview(req)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, item)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func availabilityHandlerDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "availability.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}, &models.AgentProfile{}, &models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.Conversation{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := sqls.DB()
|
||||
sqls.SetDB(db)
|
||||
t.Cleanup(func() { sqls.SetDB(previous); conn, _ := db.DB(); conn.Close() })
|
||||
for _, row := range []any{&models.User{ID: 1, Username: "one", UserType: enums.UserTypeEmployee}, &models.User{ID: 2, Username: "two", UserType: enums.UserTypeEmployee}, &models.AgentTeam{ID: 1, Name: "Support"}, &models.AgentProfile{ID: 1, UserID: 1, TeamID: 1, AgentCode: "one"}, &models.AgentProfile{ID: 2, UserID: 2, TeamID: 1, AgentCode: "two"}} {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
func callAvailabilityHandler(t *testing.T, handler gin.HandlerFunc, principal *dto.AuthPrincipal, body string) map[string]any {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
if principal != nil {
|
||||
c.Set("authPrincipal", principal)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/test", handler)
|
||||
req := httptest.NewRequest("POST", "/test", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func TestSupportAvailabilitySelfCannotTargetAnotherUser(t *testing.T) {
|
||||
db := availabilityHandlerDB(t)
|
||||
principal := &dto.AuthPrincipal{UserID: 1, Username: "one", UserType: enums.UserTypeEmployee}
|
||||
got := callAvailabilityHandler(t, SupportAvailabilityPostMe, principal, `{"userId":2,"serviceStatus":2}`)
|
||||
if got["success"] != true {
|
||||
t.Fatalf("self update: %v", got)
|
||||
}
|
||||
var own, other models.AgentProfile
|
||||
db.First(&own, 1)
|
||||
db.First(&other, 2)
|
||||
if own.ServiceStatus != enums.ServiceStatusBreak || other.ServiceStatus != enums.ServiceStatusIdle {
|
||||
t.Fatal("self endpoint changed another profile")
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilitySelfDoesNotProvisionMissingProfile(t *testing.T) {
|
||||
db := availabilityHandlerDB(t)
|
||||
principal := &dto.AuthPrincipal{UserID: 99, Username: "missing", UserType: enums.UserTypeEmployee}
|
||||
got := callAvailabilityHandler(t, SupportAvailabilityPostMe, principal, `{"serviceStatus":0}`)
|
||||
if got["success"] == true {
|
||||
t.Fatal("missing operator gained profile")
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.AgentProfile{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("profile count %d", count)
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityAdminRequiresExactPermission(t *testing.T) {
|
||||
db := availabilityHandlerDB(t)
|
||||
principal := &dto.AuthPrincipal{UserID: 1, Username: "one", Permissions: []string{constants.PermissionAgentView.Code}}
|
||||
got := callAvailabilityHandler(t, SupportAvailabilityPostAgent, principal, `{"userId":2,"serviceStatus":3}`)
|
||||
if got["success"] == true {
|
||||
t.Fatal("read permission changed another operator")
|
||||
}
|
||||
var other models.AgentProfile
|
||||
db.First(&other, 2)
|
||||
if other.ServiceStatus != enums.ServiceStatusIdle {
|
||||
t.Fatal("unauthorized mutation")
|
||||
}
|
||||
principal.Permissions = []string{constants.PermissionAgentUpdate.Code}
|
||||
got = callAvailabilityHandler(t, SupportAvailabilityPostAgent, principal, `{"userId":2,"serviceStatus":3}`)
|
||||
if got["success"] != true {
|
||||
t.Fatalf("authorized update: %v", got)
|
||||
}
|
||||
}
|
||||
func TestSupportCalendarDeploymentAndTeamPermissionsDoNotOverlap(t *testing.T) {
|
||||
db := availabilityHandlerDB(t)
|
||||
principal := &dto.AuthPrincipal{UserID: 1, Username: "one", Permissions: []string{constants.PermissionAgentTeamScheduleUpdate.Code}}
|
||||
got := callAvailabilityHandler(t, SupportAvailabilityPostCalendar, principal, `{"teamId":0,"mode":"recurring","timezone":"Europe/Berlin","weekly":[],"exceptions":[]}`)
|
||||
if got["success"] == true {
|
||||
t.Fatal("team editor changed deployment default")
|
||||
}
|
||||
principal.Permissions = []string{constants.PermissionSupportConfigUpdate.Code}
|
||||
got = callAvailabilityHandler(t, SupportAvailabilityPostCalendar, principal, `{"teamId":1,"mode":"recurring","timezone":"Europe/Berlin","weekly":[],"exceptions":[]}`)
|
||||
if got["success"] == true {
|
||||
t.Fatal("deployment editor changed team schedule")
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SupportCalendar{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("denied mutations persisted")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package models
|
||||
@@ -48,8 +47,6 @@ var Models = []any{
|
||||
&AgentProfile{},
|
||||
&AgentTeam{},
|
||||
&AgentTeamSchedule{},
|
||||
&SupportCalendar{},
|
||||
&AgentPresenceLease{},
|
||||
&AIConfig{},
|
||||
&KnowledgeBase{},
|
||||
&KnowledgeDirectory{},
|
||||
@@ -792,16 +789,12 @@ type AgentProfile struct {
|
||||
AgentCode string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // AgentCode 为客服工号,用于业务侧识别客服。
|
||||
DisplayName string `gorm:"type:varchar(100);not null;default:'';index"` // DisplayName 为客服展示名,可区别于后台昵称。
|
||||
Avatar string `gorm:"type:varchar(1024);not null;default:''"` // Avatar 为客服头像 URL。
|
||||
AvailabilityReturnStatus enums.ServiceStatus `gorm:"not null;default:0"`
|
||||
AbsenceStartAt *time.Time `gorm:"index"`
|
||||
AbsenceEndAt *time.Time `gorm:"index"`
|
||||
AssignmentVersion int64 `gorm:"not null;default:0"`
|
||||
ServiceStatus enums.ServiceStatus `gorm:"type:int;not null;default:0;index"` // ServiceStatus stores working state; effective vacation state is evaluated from its dated interval.
|
||||
MaxConcurrentCount int `gorm:"type:int;not null;default:0"` // MaxConcurrentCount limits active conversations; zero is unlimited.
|
||||
ServiceStatus enums.ServiceStatus `gorm:"type:int;not null;default:0;index"` // ServiceStatus 表示客服服务状态:0空闲 1忙碌。
|
||||
MaxConcurrentCount int `gorm:"type:int;not null;default:0"` // MaxConcurrentCount 表示客服最大并发接待数。
|
||||
PriorityLevel int `gorm:"type:int;not null;default:0;index"` // PriorityLevel 表示自动分配优先级,值越大越优先。
|
||||
AutoAssignEnabled bool `gorm:"not null;default:true;index"` // AutoAssignEnabled 表示是否参与自动分配。
|
||||
ReceiveOfflineMessage bool `gorm:"not null;default:false"` // ReceiveOfflineMessage is deprecated compatibility data; it never bypasses live eligibility.
|
||||
LastOnlineAt *time.Time `gorm:"index"` // LastOnlineAt is historical metadata, not the source of live platform presence.
|
||||
ReceiveOfflineMessage bool `gorm:"not null;default:false"` // ReceiveOfflineMessage 表示离线时是否仍接收离线消息或转接消息。
|
||||
LastOnlineAt *time.Time `gorm:"index"` // LastOnlineAt 记录最近一次在线时间。
|
||||
LastStatusAt *time.Time `gorm:"index"` // LastStatusAt 记录最近一次状态变更时间。
|
||||
Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 表示客服档案状态
|
||||
Remark string `gorm:"type:text"` // Remark 记录客服备注信息。
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SupportCalendar is opt-in. Absence of a team row preserves dated schedules.
|
||||
// TeamID zero stores the deployment-wide timezone, not another organization.
|
||||
type SupportCalendar struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TeamID int64 `gorm:"not null;uniqueIndex"`
|
||||
Mode string `gorm:"type:varchar(20);not null;default:'legacy'"`
|
||||
Timezone string `gorm:"type:varchar(100);not null;default:''"`
|
||||
WeeklyJSON string `gorm:"type:text;not null"`
|
||||
ExceptionsJSON string `gorm:"type:text;not null"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// AgentPresenceLease contains no bearer credential or client address. Multiple
|
||||
// independent connections may lease presence for the same authenticated user.
|
||||
type AgentPresenceLease struct {
|
||||
ID string `gorm:"type:varchar(64);primaryKey"`
|
||||
UserID int64 `gorm:"not null;index"`
|
||||
LoginSessionID int64 `gorm:"not null;index"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type SupportCalendarPeriod struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
}
|
||||
type SupportCalendarWeeklyPeriod struct {
|
||||
Weekday int `json:"weekday"`
|
||||
SupportCalendarPeriod
|
||||
}
|
||||
type SupportCalendarException struct {
|
||||
Date string `json:"date"`
|
||||
Closed bool `json:"closed"`
|
||||
Periods []SupportCalendarPeriod `json:"periods"`
|
||||
}
|
||||
type SupportCalendarRequest struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Mode string `json:"mode"`
|
||||
Timezone string `json:"timezone"`
|
||||
Weekly []SupportCalendarWeeklyPeriod `json:"weekly"`
|
||||
Exceptions []SupportCalendarException `json:"exceptions"`
|
||||
}
|
||||
type SupportCalendarPreviewRequest struct {
|
||||
Calendar SupportCalendarRequest `json:"calendar"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
type SupportAvailabilityRequest struct {
|
||||
UserID int64 `json:"userId"`
|
||||
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
|
||||
AbsenceStartAt string `json:"absenceStartAt"`
|
||||
AbsenceEndAt string `json:"absenceEndAt"`
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type SupportCalendarResponse struct {
|
||||
request.SupportCalendarRequest
|
||||
EffectiveTimezone string `json:"effectiveTimezone"`
|
||||
DeploymentTimezone string `json:"deploymentTimezone"`
|
||||
}
|
||||
type SupportCalendarPreviewResponse struct {
|
||||
Open bool `json:"open"`
|
||||
Timezone string `json:"timezone"`
|
||||
Mode string `json:"mode"`
|
||||
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
|
||||
}
|
||||
type SupportAvailabilityResponse struct {
|
||||
UserID int64 `json:"userId"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
|
||||
ReturnServiceStatus enums.ServiceStatus `json:"returnServiceStatus"`
|
||||
EffectiveServiceStatus enums.ServiceStatus `json:"effectiveServiceStatus"`
|
||||
AbsenceStartAt string `json:"absenceStartAt,omitempty"`
|
||||
AbsenceEndAt string `json:"absenceEndAt,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
Eligible bool `json:"eligible"`
|
||||
Reason string `json:"reason"`
|
||||
Calendar SupportCalendarResponse `json:"calendar"`
|
||||
CalendarOpen bool `json:"calendarOpen"`
|
||||
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
|
||||
}
|
||||
type PublicSupportStatusResponse struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
Timezone string `json:"timezone"`
|
||||
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
package enums
|
||||
|
||||
type AgentRunResolutionStatus string
|
||||
@@ -34,25 +33,16 @@ type ServiceStatus int
|
||||
const (
|
||||
ServiceStatusIdle ServiceStatus = 0
|
||||
ServiceStatusBusy ServiceStatus = 1
|
||||
ServiceStatusBreak ServiceStatus = 2
|
||||
ServiceStatusUnavailable ServiceStatus = 3
|
||||
ServiceStatusVacation ServiceStatus = 4
|
||||
)
|
||||
|
||||
var ServiceStatusValues = []ServiceStatus{
|
||||
ServiceStatusIdle,
|
||||
ServiceStatusBusy,
|
||||
ServiceStatusBreak,
|
||||
ServiceStatusUnavailable,
|
||||
ServiceStatusVacation,
|
||||
}
|
||||
|
||||
var serviceStatusLabelMap = map[ServiceStatus]string{
|
||||
ServiceStatusIdle: "空闲",
|
||||
ServiceStatusBusy: "忙碌",
|
||||
ServiceStatusBreak: "休息",
|
||||
ServiceStatusUnavailable: "不可用",
|
||||
ServiceStatusVacation: "休假",
|
||||
}
|
||||
|
||||
func IsValidServiceStatus(status ServiceStatus) bool {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
@@ -29,7 +28,6 @@ type Bus[T any] struct {
|
||||
|
||||
// 用于限制 PublishAsync 的 goroutine 并发数;nil 表示不限制
|
||||
asyncSem chan struct{}
|
||||
asyncWG sync.WaitGroup
|
||||
}
|
||||
|
||||
func New[T any](opts ...Option[T]) *Bus[T] {
|
||||
@@ -132,21 +130,10 @@ func (b *Bus[T]) Publish(ctx context.Context, event T) error {
|
||||
func (b *Bus[T]) PublishAsync(ctx context.Context, event T) {
|
||||
handlers := b.snapshotHandlers()
|
||||
for _, h := range handlers {
|
||||
b.asyncWG.Add(1)
|
||||
go func() {
|
||||
defer b.asyncWG.Done()
|
||||
b.callHandlerAsync(ctx, h, event)
|
||||
}()
|
||||
go b.callHandlerAsync(ctx, h, event)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until already-published asynchronous handlers and their error
|
||||
// callbacks finish. Stop external publishers before calling Wait; a handler
|
||||
// may still publish nested work while its own invocation is pending.
|
||||
func (b *Bus[T]) Wait() {
|
||||
b.asyncWG.Wait()
|
||||
}
|
||||
|
||||
func (b *Bus[T]) HandlerCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
@@ -198,21 +197,3 @@ func TestPublishAsyncAllowsReentrantPublishWithConcurrencyLimit(t *testing.T) {
|
||||
t.Fatalf("expected reentrant async publish to complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitJoinsNestedAsyncHandlersAndErrorCallbacks(t *testing.T) {
|
||||
var handled atomic.Int64
|
||||
bus := New[testEvent](WithAsyncConcurrency[testEvent](1), WithErrorHandler[testEvent](func(context.Context, error) {
|
||||
handled.Add(1)
|
||||
}))
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
if event.ID == 1 {
|
||||
bus.PublishAsync(ctx, testEvent{ID: 2})
|
||||
}
|
||||
return errors.New("expected fixture error")
|
||||
})
|
||||
bus.PublishAsync(context.Background(), testEvent{ID: 1})
|
||||
bus.Wait()
|
||||
if handled.Load() != 2 {
|
||||
t.Fatalf("Wait returned before nested work: %d error callbacks", handled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package i18nx
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const contextLocaleKey = "i18nx.locale"
|
||||
|
||||
@@ -30,27 +25,3 @@ func SetLocale(ctx *gin.Context, locale string) {
|
||||
}
|
||||
ctx.Set(contextLocaleKey, NormalizeLocale(locale))
|
||||
}
|
||||
|
||||
// localeContextKey is deliberately distinct from Gin's string key.
|
||||
type localeContextKey struct{}
|
||||
|
||||
// WithLocale carries an explicitly selected application locale without losing
|
||||
// the parent's cancellation, deadline, or values. It does not detect a locale
|
||||
// from HTTP headers or change the configured default locale.
|
||||
func WithLocale(ctx context.Context, locale string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, localeContextKey{}, NormalizeLocale(locale))
|
||||
}
|
||||
|
||||
// LocaleFromContext preserves the configured-default behavior of callers that
|
||||
// do not provide an explicit locale (including background jobs).
|
||||
func LocaleFromContext(ctx context.Context) string {
|
||||
if ctx != nil {
|
||||
if locale, ok := ctx.Value(localeContextKey{}).(string); ok {
|
||||
return NormalizeLocale(locale)
|
||||
}
|
||||
}
|
||||
return DefaultLocale
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package i18nx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWithLocalePreservesContext(t *testing.T) {
|
||||
type testKey struct{}
|
||||
deadline := time.Now().Add(time.Minute)
|
||||
parent, cancel := context.WithDeadline(context.WithValue(context.Background(), testKey{}, "request-value"), deadline)
|
||||
defer cancel()
|
||||
localized := WithLocale(parent, "ru")
|
||||
if got := LocaleFromContext(localized); got != LocaleRuRU {
|
||||
t.Fatalf("locale = %q", got)
|
||||
}
|
||||
if got := localized.Value(testKey{}); got != "request-value" {
|
||||
t.Fatalf("parent value lost: %v", got)
|
||||
}
|
||||
if got, ok := localized.Deadline(); !ok || !got.Equal(deadline) {
|
||||
t.Fatalf("deadline lost: %v, %v", got, ok)
|
||||
}
|
||||
cancel()
|
||||
if !errors.Is(localized.Err(), context.Canceled) {
|
||||
t.Fatalf("cancellation lost: %v", localized.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleFromContextDefaultsAndIsolation(t *testing.T) {
|
||||
for _, ctx := range []context.Context{nil, context.Background(), context.WithValue(context.Background(), "i18nx.locale", LocaleZhCN)} {
|
||||
if got := LocaleFromContext(ctx); got != DefaultLocale {
|
||||
t.Fatalf("missing explicit locale = %q, want default %q", got, DefaultLocale)
|
||||
}
|
||||
}
|
||||
english := WithLocale(context.Background(), LocaleEnUS)
|
||||
russian := WithLocale(english, LocaleRuRU)
|
||||
if LocaleFromContext(english) != LocaleEnUS || LocaleFromContext(russian) != LocaleRuRU {
|
||||
t.Fatal("child locale changed parent locale")
|
||||
}
|
||||
if got := LocaleFromContext(WithLocale(nil, "unsupported")); got != DefaultLocale {
|
||||
t.Fatalf("invalid locale = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -577,68 +577,3 @@ error.setup.persistenceFailed: "Setup configuration could not be read or saved.
|
||||
error.setup.bypassUnsupported: "Bypassing AI is not supported by this diagnostic. Run a human-only route check or test AI separately."
|
||||
error.channel.configChanged: "The channel configuration changed. Reload it and review your changes before saving again."
|
||||
error.channel.configUpdateFailed: "The channel configuration could not be saved. Please try again."
|
||||
|
||||
# Knowledge-base display labels and safe application-owned messages.
|
||||
knowledge.labels.unknown: "Unknown"
|
||||
knowledge.labels.type.document: "Document knowledge base"
|
||||
knowledge.labels.type.faq: "FAQ knowledge base"
|
||||
knowledge.labels.status.0: "Enabled"
|
||||
knowledge.labels.status.1: "Disabled"
|
||||
knowledge.labels.status.2: "Deleted"
|
||||
knowledge.labels.index.pending: "Pending"
|
||||
knowledge.labels.index.indexed: "Indexed"
|
||||
knowledge.labels.index.failed: "Index failed"
|
||||
knowledge.labels.mode.1: "Strict mode"
|
||||
knowledge.labels.mode.2: "Assist mode"
|
||||
knowledge.labels.channel.im: "Conversations"
|
||||
knowledge.labels.channel.agent_assist: "Agent assist"
|
||||
knowledge.labels.channel.api: "API"
|
||||
knowledge.labels.channel.debug: "Debug"
|
||||
knowledge.labels.scene.first_response: "First response"
|
||||
knowledge.labels.scene.assist: "Assisted reply"
|
||||
knowledge.labels.scene.qa: "Q&A"
|
||||
knowledge.labels.answer.1: "Answered"
|
||||
knowledge.labels.answer.2: "No answer"
|
||||
knowledge.labels.answer.3: "Fallback"
|
||||
knowledge.labels.answer.4: "Blocked"
|
||||
knowledge.labels.chunk.text: "Text"
|
||||
knowledge.labels.chunk.faq: "Q&A"
|
||||
knowledge.labels.chunk.table: "Table"
|
||||
knowledge.labels.chunk.code: "Code"
|
||||
error.knowledge.indexFailed: "Indexing failed. Check the indexing configuration and try again."
|
||||
error.knowledge.retrieveFailed: "Knowledge retrieval failed. Check the retrieval configuration and try again."
|
||||
error.knowledge.workflowReference: "This knowledge base is used by workflow “%s”. Remove it from the knowledge retrieval node first."
|
||||
error.knowledge.workflowReferences: "This knowledge base is used by %d workflows. Remove it from their knowledge retrieval nodes first."
|
||||
knowledge.rag.fallback.noAnswer: "The knowledge base currently has no clear information."
|
||||
knowledge.rag.fallback.suggestRetry: "The knowledge base did not contain enough clear information. Try asking a more specific question."
|
||||
knowledge.rag.system.assist: "You are a customer support knowledge base assistant. Prioritize the supplied knowledge excerpts. You may summarize them, but do not invent facts that were not provided. Respond in English."
|
||||
knowledge.rag.system.strict: "You are a strict customer support knowledge base assistant. Answer only from the supplied knowledge excerpts. If they are insufficient, explicitly state that the knowledge base has no clear information. Respond in English."
|
||||
knowledge.rag.userPrompt: "User question: %s\n\nReference material:\n%s"
|
||||
|
||||
knowledge.excel.question: "Primary question"
|
||||
knowledge.excel.answer: "Answer"
|
||||
knowledge.excel.similarQuestions: "Similar questions"
|
||||
knowledge.excel.remark: "Notes"
|
||||
knowledge.excel.exampleQuestion: "How do I reset my password?"
|
||||
knowledge.excel.exampleAnswer: "Open your profile settings and select Reset password."
|
||||
knowledge.excel.exampleSimilar: "I forgot my password\nWhere can I reset my password?"
|
||||
knowledge.excel.exampleRemark: "Account"
|
||||
knowledge.rag.context.faq: "[FAQ: %s]\n%s\n\n"
|
||||
knowledge.rag.context.faqTitle: "FAQ#%d"
|
||||
knowledge.rag.context.documentTitle: "Document#%d"
|
||||
knowledge.rag.context.documentSection: "[Document: %s | Section: %s]\n%s\n\n"
|
||||
knowledge.rag.context.documentHeading: "[Document: %s | Title: %s]\n%s\n\n"
|
||||
knowledge.rag.context.document: "[Document: %s]\n%s\n\n"
|
||||
knowledge.rag.faq.question: "Question: %s"
|
||||
knowledge.rag.faq.similarQuestions: "Similar questions: %s"
|
||||
knowledge.rag.faq.answer: "Answer: %s"
|
||||
knowledge.rag.faq.separator: "; "
|
||||
|
||||
# Support availability validation.
|
||||
error.supportAvailability.invalidCalendar: "Enter a valid timezone, weekly hours, and date exceptions."
|
||||
error.supportAvailability.invalidAbsence: "Vacation requires a start and end with an explicit timezone; the end must be after the start."
|
||||
error.supportAvailability.unavailable: "This operator is not currently eligible for new conversations. Refresh availability and try again."
|
||||
error.supportAvailability.persistence: "Support availability could not be loaded or saved. Please try again."
|
||||
error.supportAvailability.noProfile: "An enabled support operator profile is required."
|
||||
error.supportAvailability.invalidStatus: "Choose a valid working availability status."
|
||||
error.supportAvailability.invalidTime: "Enter a timestamp including its timezone offset."
|
||||
|
||||
@@ -576,68 +576,3 @@ error.setup.persistenceFailed: "Не удалось прочитать или с
|
||||
error.setup.bypassUnsupported: "Эта диагностика не поддерживает обход ИИ. Проверьте маршрут только с оператором или протестируйте ИИ отдельно."
|
||||
error.channel.configChanged: "Настройки канала изменились. Перезагрузите их и проверьте изменения перед повторным сохранением."
|
||||
error.channel.configUpdateFailed: "Не удалось сохранить настройки канала. Попробуйте ещё раз."
|
||||
|
||||
# Knowledge-base display labels and safe application-owned messages.
|
||||
knowledge.labels.unknown: "Неизвестно"
|
||||
knowledge.labels.type.document: "База знаний документов"
|
||||
knowledge.labels.type.faq: "База знаний FAQ"
|
||||
knowledge.labels.status.0: "Включено"
|
||||
knowledge.labels.status.1: "Отключено"
|
||||
knowledge.labels.status.2: "Удалено"
|
||||
knowledge.labels.index.pending: "Ожидает индексации"
|
||||
knowledge.labels.index.indexed: "Проиндексировано"
|
||||
knowledge.labels.index.failed: "Ошибка индексации"
|
||||
knowledge.labels.mode.1: "Строгий режим"
|
||||
knowledge.labels.mode.2: "Вспомогательный режим"
|
||||
knowledge.labels.channel.im: "Диалоги"
|
||||
knowledge.labels.channel.agent_assist: "Помощник оператора"
|
||||
knowledge.labels.channel.api: "API"
|
||||
knowledge.labels.channel.debug: "Отладка"
|
||||
knowledge.labels.scene.first_response: "Первый ответ"
|
||||
knowledge.labels.scene.assist: "Подсказка ответа"
|
||||
knowledge.labels.scene.qa: "Вопросы и ответы"
|
||||
knowledge.labels.answer.1: "Ответ получен"
|
||||
knowledge.labels.answer.2: "Нет ответа"
|
||||
knowledge.labels.answer.3: "Резервный ответ"
|
||||
knowledge.labels.answer.4: "Заблокирован"
|
||||
knowledge.labels.chunk.text: "Текст"
|
||||
knowledge.labels.chunk.faq: "Вопросы и ответы"
|
||||
knowledge.labels.chunk.table: "Таблица"
|
||||
knowledge.labels.chunk.code: "Код"
|
||||
error.knowledge.indexFailed: "Не удалось выполнить индексацию. Проверьте настройки индексации и повторите попытку."
|
||||
error.knowledge.retrieveFailed: "Не удалось выполнить поиск по базе знаний. Проверьте настройки поиска и повторите попытку."
|
||||
error.knowledge.workflowReference: "Эта база знаний используется в процессе «%s». Сначала удалите её из узла поиска по базе знаний."
|
||||
error.knowledge.workflowReferences: "Эта база знаний используется в %d процессах. Сначала удалите её из узлов поиска по базе знаний."
|
||||
knowledge.rag.fallback.noAnswer: "В базе знаний пока нет однозначной информации."
|
||||
knowledge.rag.fallback.suggestRetry: "В базе знаний недостаточно однозначной информации. Попробуйте задать более конкретный вопрос."
|
||||
knowledge.rag.system.assist: "Вы помощник службы поддержки по базе знаний. Отвечайте прежде всего на основе предоставленных фрагментов. Можно обобщать их, но нельзя придумывать отсутствующие факты. Отвечайте на русском языке."
|
||||
knowledge.rag.system.strict: "Вы помощник службы поддержки, строго использующий базу знаний. Отвечайте только на основе предоставленных фрагментов. Если их недостаточно, прямо сообщите, что в базе знаний нет однозначной информации. Отвечайте на русском языке."
|
||||
knowledge.rag.userPrompt: "Вопрос пользователя: %s\n\nСправочные материалы:\n%s"
|
||||
|
||||
knowledge.excel.question: "Основной вопрос"
|
||||
knowledge.excel.answer: "Ответ"
|
||||
knowledge.excel.similarQuestions: "Похожие вопросы"
|
||||
knowledge.excel.remark: "Примечания"
|
||||
knowledge.excel.exampleQuestion: "Как сбросить пароль?"
|
||||
knowledge.excel.exampleAnswer: "Откройте настройки профиля и выберите «Сбросить пароль»."
|
||||
knowledge.excel.exampleSimilar: "Я забыл пароль\nГде можно сбросить пароль?"
|
||||
knowledge.excel.exampleRemark: "Учётная запись"
|
||||
knowledge.rag.context.faq: "[FAQ: %s]\n%s\n\n"
|
||||
knowledge.rag.context.faqTitle: "FAQ#%d"
|
||||
knowledge.rag.context.documentTitle: "Документ №%d"
|
||||
knowledge.rag.context.documentSection: "[Документ: %s | Раздел: %s]\n%s\n\n"
|
||||
knowledge.rag.context.documentHeading: "[Документ: %s | Заголовок: %s]\n%s\n\n"
|
||||
knowledge.rag.context.document: "[Документ: %s]\n%s\n\n"
|
||||
knowledge.rag.faq.question: "Вопрос: %s"
|
||||
knowledge.rag.faq.similarQuestions: "Похожие вопросы: %s"
|
||||
knowledge.rag.faq.answer: "Ответ: %s"
|
||||
knowledge.rag.faq.separator: "; "
|
||||
|
||||
# Support availability validation.
|
||||
error.supportAvailability.invalidCalendar: "Укажите корректный часовой пояс, часы работы по дням недели и исключения по датам."
|
||||
error.supportAvailability.invalidAbsence: "Для отпуска укажите начало и окончание с часовым поясом. Окончание должно быть позже начала."
|
||||
error.supportAvailability.unavailable: "Этот оператор сейчас не может принимать новые обращения. Обновите данные о доступности и повторите попытку."
|
||||
error.supportAvailability.persistence: "Не удалось загрузить или сохранить настройки доступности поддержки. Повторите попытку."
|
||||
error.supportAvailability.noProfile: "Требуется активный профиль оператора поддержки."
|
||||
error.supportAvailability.invalidStatus: "Выберите корректный рабочий статус доступности."
|
||||
error.supportAvailability.invalidTime: "Укажите дату и время со смещением часового пояса."
|
||||
|
||||
@@ -577,68 +577,3 @@ error.setup.persistenceFailed: "无法读取或保存设置配置,未提交不
|
||||
error.setup.bypassUnsupported: "此诊断不支持绕过 AI。请检查仅人工路由,或单独测试 AI。"
|
||||
error.channel.configChanged: "渠道配置已更改。请重新加载并检查更改后再保存。"
|
||||
error.channel.configUpdateFailed: "无法保存渠道配置,请重试。"
|
||||
|
||||
# Knowledge-base display labels and safe application-owned messages.
|
||||
knowledge.labels.unknown: "未知"
|
||||
knowledge.labels.type.document: "文档知识库"
|
||||
knowledge.labels.type.faq: "FAQ知识库"
|
||||
knowledge.labels.status.0: "启用"
|
||||
knowledge.labels.status.1: "禁用"
|
||||
knowledge.labels.status.2: "已删除"
|
||||
knowledge.labels.index.pending: "待索引"
|
||||
knowledge.labels.index.indexed: "已索引"
|
||||
knowledge.labels.index.failed: "索引失败"
|
||||
knowledge.labels.mode.1: "严格模式"
|
||||
knowledge.labels.mode.2: "辅助模式"
|
||||
knowledge.labels.channel.im: "客服会话"
|
||||
knowledge.labels.channel.agent_assist: "客服助手"
|
||||
knowledge.labels.channel.api: "API接口"
|
||||
knowledge.labels.channel.debug: "调试"
|
||||
knowledge.labels.scene.first_response: "首次回复"
|
||||
knowledge.labels.scene.assist: "辅助回复"
|
||||
knowledge.labels.scene.qa: "问答"
|
||||
knowledge.labels.answer.1: "正常"
|
||||
knowledge.labels.answer.2: "无答案"
|
||||
knowledge.labels.answer.3: "兜底回复"
|
||||
knowledge.labels.answer.4: "已屏蔽"
|
||||
knowledge.labels.chunk.text: "文本"
|
||||
knowledge.labels.chunk.faq: "问答"
|
||||
knowledge.labels.chunk.table: "表格"
|
||||
knowledge.labels.chunk.code: "代码"
|
||||
error.knowledge.indexFailed: "索引失败。请检查索引配置后重试。"
|
||||
error.knowledge.retrieveFailed: "知识检索失败。请检查检索配置后重试。"
|
||||
error.knowledge.workflowReference: "知识库正在被流程「%s」使用,请先从知识检索节点中移除"
|
||||
error.knowledge.workflowReferences: "知识库正在被 %d 个流程使用,请先从知识检索节点中移除"
|
||||
knowledge.rag.fallback.noAnswer: "当前知识库暂无明确信息。"
|
||||
knowledge.rag.fallback.suggestRetry: "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
|
||||
knowledge.rag.system.assist: "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。请使用中文回答。"
|
||||
knowledge.rag.system.strict: "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。请使用中文回答。"
|
||||
knowledge.rag.userPrompt: "用户问题:%s\n\n参考资料:\n%s"
|
||||
|
||||
knowledge.excel.question: "标准问题"
|
||||
knowledge.excel.answer: "答案"
|
||||
knowledge.excel.similarQuestions: "相似问"
|
||||
knowledge.excel.remark: "备注"
|
||||
knowledge.excel.exampleQuestion: "如何重置密码?"
|
||||
knowledge.excel.exampleAnswer: "进入个人设置后点击重置密码。"
|
||||
knowledge.excel.exampleSimilar: "忘记密码怎么办\n重置密码在哪里"
|
||||
knowledge.excel.exampleRemark: "账号"
|
||||
knowledge.rag.context.faq: "【FAQ:%s】\n%s\n\n"
|
||||
knowledge.rag.context.faqTitle: "FAQ#%d"
|
||||
knowledge.rag.context.documentTitle: "文档#%d"
|
||||
knowledge.rag.context.documentSection: "【文档:%s|章节:%s】\n%s\n\n"
|
||||
knowledge.rag.context.documentHeading: "【文档:%s|标题:%s】\n%s\n\n"
|
||||
knowledge.rag.context.document: "【文档:%s】\n%s\n\n"
|
||||
knowledge.rag.faq.question: "问题:%s"
|
||||
knowledge.rag.faq.similarQuestions: "相似问:%s"
|
||||
knowledge.rag.faq.answer: "回答:%s"
|
||||
knowledge.rag.faq.separator: ";"
|
||||
|
||||
# Support availability validation.
|
||||
error.supportAvailability.invalidCalendar: "请输入有效的时区、每周工作时间和特殊日期规则。"
|
||||
error.supportAvailability.invalidAbsence: "休假需要包含明确时区的开始和结束时间,结束时间必须晚于开始时间。"
|
||||
error.supportAvailability.unavailable: "该客服当前无法接收新会话。请刷新可用状态后重试。"
|
||||
error.supportAvailability.persistence: "无法加载或保存客服可用状态。请重试。"
|
||||
error.supportAvailability.noProfile: "需要已启用的客服资料。"
|
||||
error.supportAvailability.invalidStatus: "请选择有效的工作状态。"
|
||||
error.supportAvailability.invalidTime: "请输入包含时区偏移的日期和时间。"
|
||||
|
||||
@@ -275,29 +275,21 @@ func itoa(value int64) string {
|
||||
func TestRequireSignedIdentityRejectsUnsignedAndInvalidSignatures(t *testing.T) {
|
||||
for _, signature := range []string{"", "invalid"} {
|
||||
ctx, _ := newIdentityContext(map[string]string{"X-External-Id": testUserID, "X-External-Id-Signature": signature})
|
||||
if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil {
|
||||
t.Fatal("signed-only policy accepted an unsigned or invalid identity")
|
||||
}
|
||||
}
|
||||
ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, testUserID, time.Now()))
|
||||
user, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true})
|
||||
if err != nil || user.IdentityScope != IdentityScope(testChannelID, "hmac") {
|
||||
t.Fatal("valid signed identity did not retain its HMAC scope")
|
||||
if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil { t.Fatal("signed-only policy accepted an unsigned or invalid identity") }
|
||||
}
|
||||
ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret,testChannelID,testUserID,time.Now()))
|
||||
user, err := GetExternalUser(ctx, Secrets{ChannelID:testChannelID,IdentityHMAC:testSecret,RequireSignedIdentity:true})
|
||||
if err != nil || user.IdentityScope != IdentityScope(testChannelID,"hmac") { t.Fatal("valid signed identity did not retain its HMAC scope") }
|
||||
}
|
||||
|
||||
func TestIdentityScopeSeparatesIssuersAndChannels(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, channel := range []string{"channel-one", "channel-two"} {
|
||||
for _, issuer := range []string{"guest", "hmac", "jwt"} {
|
||||
scope := IdentityScope(channel, issuer)
|
||||
if scope == "" || seen[scope] {
|
||||
t.Fatal("identity scopes collided")
|
||||
}
|
||||
for _, channel := range []string{"channel-one","channel-two"} {
|
||||
for _, issuer := range []string{"guest","hmac","jwt"} {
|
||||
scope := IdentityScope(channel,issuer)
|
||||
if scope == "" || seen[scope] { t.Fatal("identity scopes collided") }
|
||||
seen[scope] = true
|
||||
}
|
||||
}
|
||||
if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" {
|
||||
t.Fatal("invalid scope inputs accepted")
|
||||
}
|
||||
if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" { t.Fatal("invalid scope inputs accepted") }
|
||||
}
|
||||
|
||||
@@ -284,14 +284,8 @@ func decodeExternalDisplayName(s string) string {
|
||||
// the issuer's history. Reassigning a channel to a different issuer requires a new
|
||||
// channel, not silently reusing that issuer's namespace.
|
||||
func IdentityScope(channelCode, issuer string) string {
|
||||
if strings.TrimSpace(channelCode) == "" {
|
||||
return ""
|
||||
}
|
||||
switch issuer {
|
||||
case "jwt", "hmac", "guest":
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(channelCode) == "" { return "" }
|
||||
switch issuer { case "jwt", "hmac", "guest": default: return "" }
|
||||
digest := sha256.Sum256([]byte(strings.TrimSpace(channelCode)))
|
||||
return "v1:" + issuer + ":" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
// available in SQLite and MySQL and compares exact bytes even when MySQL's text
|
||||
// column uses a case-insensitive or trailing-space-insensitive collation.
|
||||
func (r *channelRepository) UpdatesConfigIfUnchanged(db *gorm.DB, expected *models.Channel, columns map[string]any) (bool, error) {
|
||||
if expected == nil || expected.ID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if expected == nil || expected.ID <= 0 { return false, nil }
|
||||
result := db.Model(&models.Channel{}).
|
||||
Where("id = ? AND status <> ? AND updated_at = ?", expected.ID, enums.StatusDeleted, expected.UpdatedAt).
|
||||
Where("HEX(COALESCE(config_json, '')) = HEX(?)", expected.ConfigJSON).
|
||||
|
||||
@@ -40,9 +40,7 @@ func (r *customerIdentityRepository) Take(db *gorm.DB, where ...interface{}) *mo
|
||||
// GetBy is the legacy/internal-integration lookup. Ambiguous mappings fail closed.
|
||||
func (r *customerIdentityRepository) GetBy(db *gorm.DB, externalSource enums.ExternalSource, externalID string) *models.CustomerIdentity {
|
||||
identity, err := r.GetUniqueBy(db, "", externalSource, externalID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil { return nil }
|
||||
return identity
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
@@ -30,21 +30,11 @@ func (r *customerIdentityRepository) GetUniqueBy(db *gorm.DB, scope string, sour
|
||||
query = db.Where("scoped_key = ? OR (scoped_key IS NULL AND identity_scope = ? AND external_source = ? AND external_id = ?)", *key, scope, source, strings.TrimSpace(externalID))
|
||||
}
|
||||
err := query.Limit(2).Find(&identities).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(identities) > 1 {
|
||||
return nil, ErrAmbiguousCustomerIdentity
|
||||
}
|
||||
if len(identities) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if identities[0].IdentityScope != scope || identities[0].ExternalSource != source || identities[0].ExternalID != strings.TrimSpace(externalID) {
|
||||
return nil, ErrAmbiguousCustomerIdentity
|
||||
}
|
||||
if identities[0].Status != enums.StatusOk {
|
||||
return nil, errors.New("inactive customer identity")
|
||||
}
|
||||
if err != nil { return nil, err }
|
||||
if len(identities) > 1 { return nil, ErrAmbiguousCustomerIdentity }
|
||||
if len(identities) == 0 { return nil, nil }
|
||||
if identities[0].IdentityScope != scope || identities[0].ExternalSource != source || identities[0].ExternalID != strings.TrimSpace(externalID) { return nil, ErrAmbiguousCustomerIdentity }
|
||||
if identities[0].Status != enums.StatusOk { return nil, errors.New("inactive customer identity") }
|
||||
return &identities[0], nil
|
||||
}
|
||||
|
||||
@@ -53,9 +43,7 @@ func (r *customerIdentityRepository) GetUniqueBy(db *gorm.DB, scope string, sour
|
||||
// SQLite and MySQL permit multiple NULL values in a unique index. Encoding an
|
||||
// array avoids delimiter collisions and hashing avoids collation ambiguity.
|
||||
func ScopedCustomerIdentityKey(scope string, source enums.ExternalSource, externalID string) *string {
|
||||
if scope == "" {
|
||||
return nil
|
||||
}
|
||||
if scope == "" { return nil }
|
||||
encoded, _ := json.Marshal([]string{scope, string(source), strings.TrimSpace(externalID)})
|
||||
digest := sha256.Sum256(encoded)
|
||||
key := hex.EncodeToString(digest[:])
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"errors"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"time"
|
||||
)
|
||||
|
||||
var SupportAvailabilityRepository = &supportAvailabilityRepository{}
|
||||
|
||||
type supportAvailabilityRepository struct{}
|
||||
|
||||
func (r *supportAvailabilityRepository) Calendar(db *gorm.DB, teamID int64) (*models.SupportCalendar, error) {
|
||||
var item models.SupportCalendar
|
||||
err := db.Where("team_id = ?", teamID).Take(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) SaveCalendar(db *gorm.DB, item *models.SupportCalendar) error {
|
||||
return db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "team_id"}}, DoUpdates: clause.AssignmentColumns([]string{"mode", "timezone", "weekly_json", "exceptions_json", "updated_at"})}).Create(item).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) RenewPresence(db *gorm.DB, item *models.AgentPresenceLease) error {
|
||||
return db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"expires_at"})}).Create(item).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) EndPresence(db *gorm.DB, id string, at time.Time) error {
|
||||
return db.Model(&models.AgentPresenceLease{}).Where("id = ? AND expires_at > ?", id, at).Update("expires_at", at).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) PrunePresence(db *gorm.DB, now time.Time) error {
|
||||
return db.Where("expires_at <= ?", now).Delete(&models.AgentPresenceLease{}).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) OnlineUsers(db *gorm.DB, userIDs []int64, now time.Time) (map[int64]bool, error) {
|
||||
result := map[int64]bool{}
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var leases []models.AgentPresenceLease
|
||||
if err := db.Where("user_id IN ? AND expires_at > ?", userIDs, now).Find(&leases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionIDs := make([]int64, 0, len(leases))
|
||||
for _, lease := range leases {
|
||||
sessionIDs = append(sessionIDs, lease.LoginSessionID)
|
||||
}
|
||||
if len(sessionIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var sessions []models.LoginSession
|
||||
if err := db.Select("id", "user_id", "expired_at", "revoked_at").Where("id IN ? AND expired_at > ? AND revoked_at IS NULL", sessionIDs, now).Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
valid := map[int64]int64{}
|
||||
for _, session := range sessions {
|
||||
valid[session.ID] = session.UserID
|
||||
}
|
||||
for _, lease := range leases {
|
||||
if valid[lease.LoginSessionID] == lease.UserID {
|
||||
result[lease.UserID] = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// A portable write acquires the profile row's write lock on MySQL and the
|
||||
// transaction write lock on SQLite. All assignment paths take this before count.
|
||||
func (r *supportAvailabilityRepository) LockProfile(db *gorm.DB, userID int64) (*models.AgentProfile, error) {
|
||||
result := db.Model(&models.AgentProfile{}).Where("user_id = ?", userID).UpdateColumn("assignment_version", gorm.Expr("assignment_version + 1"))
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var item models.AgentProfile
|
||||
err := db.Where("user_id = ?", userID).Take(&item).Error
|
||||
return &item, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) ActiveCount(db *gorm.DB, userID, excludeConversationID int64) (int, error) {
|
||||
var count int64
|
||||
q := db.Model(&models.Conversation{}).Where("current_assignee_id = ? AND status = ?", userID, enums.IMConversationStatusActive)
|
||||
if excludeConversationID > 0 {
|
||||
q = q.Where("id <> ?", excludeConversationID)
|
||||
}
|
||||
err := q.Count(&count).Error
|
||||
return int(count), err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) LegacySchedules(db *gorm.DB, teamID int64, now time.Time) ([]models.AgentTeamSchedule, error) {
|
||||
var items []models.AgentTeamSchedule
|
||||
err := db.Where("team_id = ? AND status = ? AND end_at > ?", teamID, enums.StatusOk, now).Order("start_at ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) Profiles(db *gorm.DB, teamIDs []int64) ([]models.AgentProfile, error) {
|
||||
var items []models.AgentProfile
|
||||
q := db.Where("status <> ?", enums.StatusDeleted)
|
||||
if teamIDs != nil {
|
||||
if len(teamIDs) == 0 {
|
||||
return []models.AgentProfile{}, nil
|
||||
}
|
||||
q = q.Where("team_id IN ?", teamIDs)
|
||||
}
|
||||
err := q.Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) User(db *gorm.DB, id int64) (*models.User, error) {
|
||||
var item models.User
|
||||
err := db.First(&item, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) Team(db *gorm.DB, id int64) (*models.AgentTeam, error) {
|
||||
var item models.AgentTeam
|
||||
err := db.First(&item, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func (r *supportAvailabilityRepository) AssignConversation(db *gorm.DB, previous *models.Conversation, values map[string]any) error {
|
||||
result := db.Model(&models.Conversation{}).Where("id = ? AND status = ? AND current_assignee_id = ?", previous.ID, previous.Status, previous.CurrentAssigneeID).Updates(values)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return gorm.ErrInvalidTransaction
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *supportAvailabilityRepository) ProfileByUser(db *gorm.DB, userID int64) (*models.AgentProfile, error) {
|
||||
var item models.AgentProfile
|
||||
err := db.Where("user_id = ?", userID).Take(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
|
||||
// EffectiveStatusCondition mirrors the vacation-boundary policy for paginated
|
||||
// list filters. Filtering the stored Vacation value would contradict the badge.
|
||||
func (r *supportAvailabilityRepository) EffectiveStatusCondition(cnd *sqls.Cnd, status enums.ServiceStatus, now time.Time) {
|
||||
cnd.Where(`(CASE WHEN service_status <> ? THEN service_status
|
||||
WHEN absence_start_at IS NOT NULL AND absence_end_at IS NOT NULL AND absence_start_at <= ? AND absence_end_at > ? THEN ?
|
||||
WHEN availability_return_status >= ? AND availability_return_status <= ? THEN availability_return_status
|
||||
ELSE ? END) = ?`, enums.ServiceStatusVacation, now, now, enums.ServiceStatusVacation, enums.ServiceStatusIdle, enums.ServiceStatusUnavailable, enums.ServiceStatusUnavailable, status)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
const agentPresenceLeaseTTL = 90 * time.Second
|
||||
const agentPresenceDisconnectGrace = 15 * time.Second
|
||||
const agentPresenceRenewInterval = 15 * time.Second
|
||||
|
||||
func (s *wsService) renewAgentPresence(session *ClientSession) {
|
||||
if session == nil || session.Closed.Load() || session.Principal == nil || (session.Role != realtimeRoleAdmin && session.Role != realtimeRoleNotification) {
|
||||
return
|
||||
}
|
||||
session.presenceMu.Lock()
|
||||
dispatchPending := false
|
||||
defer func() {
|
||||
session.presenceMu.Unlock()
|
||||
// Finish the presence write before dispatching, and keep ownership in
|
||||
// this call rather than launching an untracked database goroutine.
|
||||
if dispatchPending {
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
}
|
||||
}()
|
||||
if session.PresenceEnded {
|
||||
return
|
||||
}
|
||||
principal := session.Principal
|
||||
if principal.UserType != enums.UserTypeEmployee || !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
last := session.PresenceRenewedAt.Load()
|
||||
if now.Unix()-last < int64(agentPresenceRenewInterval/time.Second) {
|
||||
return
|
||||
}
|
||||
if !session.PresenceRenewedAt.CompareAndSwap(last, now.Unix()) {
|
||||
return
|
||||
}
|
||||
// The internal ID is generated independently per connection, across processes.
|
||||
expires := now.Add(agentPresenceLeaseTTL)
|
||||
if principal.ExpiresAt.Before(expires) {
|
||||
expires = principal.ExpiresAt
|
||||
}
|
||||
lease := models.AgentPresenceLease{ID: session.PresenceLeaseID, UserID: principal.UserID, LoginSessionID: principal.LoginSessionID, ExpiresAt: expires}
|
||||
if lease.ID == "" {
|
||||
return
|
||||
}
|
||||
if err := repositories.SupportAvailabilityRepository.RenewPresence(sqls.DB(), &lease); err != nil {
|
||||
slog.Warn("support presence renewal failed")
|
||||
return
|
||||
}
|
||||
if last == 0 {
|
||||
dispatchPending = true
|
||||
}
|
||||
}
|
||||
func (s *wsService) endAgentPresence(session *ClientSession) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.presenceMu.Lock()
|
||||
defer session.presenceMu.Unlock()
|
||||
session.PresenceEnded = true
|
||||
if session.PresenceLeaseID == "" || session.PresenceRenewedAt.Load() == 0 {
|
||||
return
|
||||
}
|
||||
if err := repositories.SupportAvailabilityRepository.EndPresence(sqls.DB(), session.PresenceLeaseID, time.Now().Add(agentPresenceDisconnectGrace)); err != nil {
|
||||
slog.Warn("support presence disconnect update failed")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -81,18 +79,11 @@ func (s *agentProfileService) GetUserIDsByTeamID(teamID int64) []int64 {
|
||||
|
||||
// GetDispatchAgents 获取可用于分配会话的客服
|
||||
func (s *agentProfileService) GetDispatchAgents(teamIds []int64) []models.AgentProfile {
|
||||
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIds)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]models.AgentProfile, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), profile, true, time.Now(), 0)
|
||||
if err == nil && eligibility.Eligible {
|
||||
result = append(result, profile)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return AgentProfileService.Find(sqls.NewCnd().
|
||||
In("team_id", teamIds).
|
||||
Eq("status", enums.StatusOk).
|
||||
Eq("auto_assign_enabled", true).
|
||||
Eq("service_status", enums.ServiceStatusIdle))
|
||||
}
|
||||
|
||||
func (s *agentProfileService) CreateAgentProfile(req request.CreateAgentProfileRequest, operator *dto.AuthPrincipal) (*models.AgentProfile, error) {
|
||||
@@ -129,7 +120,7 @@ func (s *agentProfileService) UpdateAgentProfile(req request.UpdateAgentProfileR
|
||||
"agent_code": item.AgentCode,
|
||||
"display_name": item.DisplayName,
|
||||
"avatar": item.Avatar,
|
||||
// Availability is managed only through the dedicated guarded endpoint.
|
||||
"service_status": item.ServiceStatus,
|
||||
"max_concurrent_count": item.MaxConcurrentCount,
|
||||
"priority_level": item.PriorityLevel,
|
||||
"auto_assign_enabled": item.AutoAssignEnabled,
|
||||
@@ -178,7 +169,7 @@ func (s *agentProfileService) buildProfileModel(id int64, req request.CreateAgen
|
||||
if exists := s.Take("agent_code = ? AND id <> ?", req.AgentCode, id); exists != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0163")
|
||||
}
|
||||
if !enums.IsValidServiceStatus(req.ServiceStatus) || (id == 0 && req.ServiceStatus == enums.ServiceStatusVacation) {
|
||||
if !enums.IsValidServiceStatus(req.ServiceStatus) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0165")
|
||||
}
|
||||
if req.MaxConcurrentCount < 0 {
|
||||
@@ -206,23 +197,11 @@ func (s *agentProfileService) dispatchPendingConversationsIfEligible(item *model
|
||||
if item.Status != enums.StatusOk {
|
||||
return
|
||||
}
|
||||
if !item.AutoAssignEnabled {
|
||||
if !item.AutoAssignEnabled || item.MaxConcurrentCount <= 0 {
|
||||
return
|
||||
}
|
||||
if effectiveSupportStatus(*item, time.Now()) != enums.ServiceStatusIdle {
|
||||
if item.ServiceStatus != enums.ServiceStatusIdle {
|
||||
return
|
||||
}
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) FilterEffectiveServiceStatus(cnd *sqls.Cnd, value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
number, err := strconv.Atoi(value)
|
||||
if err != nil || !enums.IsValidServiceStatus(enums.ServiceStatus(number)) {
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.invalidStatus")
|
||||
}
|
||||
repositories.SupportAvailabilityRepository.EffectiveStatusCondition(cnd, enums.ServiceStatus(number), time.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -123,14 +123,10 @@ func TestRevalidatePrincipalRejectsRevokedExpiredOrUnboundIdentity(t *testing.T)
|
||||
{"original deadline passed", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now.Add(-time.Second); return nil }},
|
||||
{"revoked session", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", f.now).Error }},
|
||||
{"session deadline reached", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now).Error }},
|
||||
{"session deadline passed", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second)).Error
|
||||
}},
|
||||
{"session deadline passed", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second)).Error }},
|
||||
{"deleted session", func(f *authSessionFixture) error { return f.db.Delete(&f.session).Error }},
|
||||
{"different session owner", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("user_id", f.user.ID+1).Error }},
|
||||
{"disabled user", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error
|
||||
}},
|
||||
{"different session owner", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("user_id", f.user.ID + 1).Error }},
|
||||
{"disabled user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error }},
|
||||
{"soft-deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", f.now).Error }},
|
||||
{"deleted user", func(f *authSessionFixture) error { return f.db.Delete(&f.user).Error }},
|
||||
{"failed session query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.LoginSession{}) }},
|
||||
@@ -156,21 +152,11 @@ func TestRevalidatePrincipalRefreshesScopeWithoutMutatingSnapshot(t *testing.T)
|
||||
change func(*authSessionFixture) error
|
||||
wantRoles []string
|
||||
}{
|
||||
{"disabled role", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error
|
||||
}, nil},
|
||||
{"removed role", func(f *authSessionFixture) error {
|
||||
return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error
|
||||
}, nil},
|
||||
{"removed role permission", func(f *authSessionFixture) error {
|
||||
return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error
|
||||
}, []string{"support"}},
|
||||
{"disabled permission", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error
|
||||
}, []string{"support"}},
|
||||
{"direct deny", func(f *authSessionFixture) error {
|
||||
return f.db.Create(&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}).Error
|
||||
}, []string{"support"}},
|
||||
{"disabled role", func(f *authSessionFixture) error { return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error }, nil},
|
||||
{"removed role", func(f *authSessionFixture) error { return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error }, nil},
|
||||
{"removed role permission", func(f *authSessionFixture) error { return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error }, []string{"support"}},
|
||||
{"disabled permission", func(f *authSessionFixture) error { return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error }, []string{"support"}},
|
||||
{"direct deny", func(f *authSessionFixture) error { return f.db.Create(&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}).Error }, []string{"support"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -227,7 +213,7 @@ func TestAuthenticateBindsPrincipalToSessionWithoutExposingMetadata(t *testing.T
|
||||
t.Cleanup(func() { sqls.SetDB(previousDB) })
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("GET", "/", nil)
|
||||
ctx.Request.Header.Set("Authorization", "Bearer "+f.session.Token)
|
||||
ctx.Request.Header.Set("Authorization", "Bearer " + f.session.Token)
|
||||
principal, err := newAuthService().Authenticate(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,103 +16,63 @@ import (
|
||||
)
|
||||
|
||||
func TestChannelConfigCompareAndSwapRejectsStalePolicyAndSecretWrites(t *testing.T) {
|
||||
for _, action := range []string{"editor", "secret-reset"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
previousDB := sqls.DB()
|
||||
db := setupChannelServiceTestDB(t)
|
||||
t.Cleanup(func() { sqls.SetDB(previousDB) })
|
||||
agent := createChannelServiceTestAgent(t, db, 1001)
|
||||
channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Policy channel", Status: int(enums.StatusOk), ConfigJSON: `{"requireSignedIdentity":false,"userTokenSecret":"test-old-secret"}`}, channelServiceTestOperator())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action:=range []string{"editor", "secret-reset"} {
|
||||
t.Run(action,func(t *testing.T) {
|
||||
previousDB:=sqls.DB()
|
||||
db:=setupChannelServiceTestDB(t)
|
||||
t.Cleanup(func(){sqls.SetDB(previousDB)})
|
||||
agent:=createChannelServiceTestAgent(t,db,1001)
|
||||
channel,err:=ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Policy channel",Status:int(enums.StatusOk),ConfigJSON:`{"requireSignedIdentity":false,"userTokenSecret":"test-old-secret"}`},channelServiceTestOperator())
|
||||
if err!=nil {t.Fatal(err)}
|
||||
var injected atomic.Bool
|
||||
callbackName := "test:concurrent_channel_policy"
|
||||
callbackName:="test:concurrent_channel_policy"
|
||||
// Commit another writer after the stale service read, just before its SQL.
|
||||
// Preserve updated_at deliberately: bytewise JSON CAS must also catch this.
|
||||
if err := db.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema == nil || tx.Statement.Schema.Table != "t_channel" || !injected.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
cfg, parseErr := ChannelService.ParseWebChannelConfig(channel.ConfigJSON)
|
||||
if parseErr != nil {
|
||||
tx.AddError(parseErr)
|
||||
return
|
||||
}
|
||||
cfg.RequireSignedIdentity = true
|
||||
cfg.UserTokenSecret = "test-new-secret"
|
||||
encoded, encodeErr := json.Marshal(cfg)
|
||||
if encodeErr != nil {
|
||||
tx.AddError(encodeErr)
|
||||
return
|
||||
}
|
||||
if writeErr := db.Session(&gorm.Session{NewDB: true}).Model(&models.Channel{}).Where("id = ?", channel.ID).Updates(map[string]any{"config_json": string(encoded), "updated_at": channel.UpdatedAt}).Error; writeErr != nil {
|
||||
tx.AddError(writeErr)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
|
||||
if err:=db.Callback().Update().Before("gorm:update").Register(callbackName,func(tx *gorm.DB){
|
||||
if tx.Statement.Schema==nil || tx.Statement.Schema.Table!="t_channel" || !injected.CompareAndSwap(false,true){return}
|
||||
cfg,parseErr:=ChannelService.ParseWebChannelConfig(channel.ConfigJSON)
|
||||
if parseErr!=nil {tx.AddError(parseErr);return}
|
||||
cfg.RequireSignedIdentity=true
|
||||
cfg.UserTokenSecret="test-new-secret"
|
||||
encoded,encodeErr:=json.Marshal(cfg)
|
||||
if encodeErr!=nil {tx.AddError(encodeErr);return}
|
||||
if writeErr:=db.Session(&gorm.Session{NewDB:true}).Model(&models.Channel{}).Where("id = ?",channel.ID).Updates(map[string]any{"config_json":string(encoded),"updated_at":channel.UpdatedAt}).Error;writeErr!=nil{tx.AddError(writeErr)}
|
||||
});err!=nil{t.Fatal(err)}
|
||||
t.Cleanup(func(){_ = db.Callback().Update().Remove(callbackName)})
|
||||
switch action {
|
||||
case "editor":
|
||||
err = ChannelService.UpdateChannel(request.UpdateChannelRequest{ID: channel.ID, CreateChannelRequest: request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Old editor title", Status: int(enums.StatusOk), ConfigJSON: `{"title":"old editor"}`}}, channelServiceTestOperator())
|
||||
case "secret-reset":
|
||||
var secret string
|
||||
secret, err = ChannelService.ResetUserTokenSecret(channel.ID, channelServiceTestOperator())
|
||||
if secret != "" {
|
||||
t.Fatal("failed reset returned an uncommitted secret")
|
||||
}
|
||||
}
|
||||
if !injected.Load() || err == nil {
|
||||
t.Fatal("stale writer was not rejected")
|
||||
}
|
||||
current := ChannelService.Get(channel.ID)
|
||||
cfg, parseErr := ChannelService.ParseWebChannelConfig(current.ConfigJSON)
|
||||
if parseErr != nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret != "test-new-secret" {
|
||||
t.Fatal("stale writer replaced the concurrent policy/secret")
|
||||
case "editor": err=ChannelService.UpdateChannel(request.UpdateChannelRequest{ID:channel.ID,CreateChannelRequest:request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Old editor title",Status:int(enums.StatusOk),ConfigJSON:`{"title":"old editor"}`}},channelServiceTestOperator())
|
||||
case "secret-reset": var secret string;secret,err=ChannelService.ResetUserTokenSecret(channel.ID,channelServiceTestOperator());if secret!=""{t.Fatal("failed reset returned an uncommitted secret")}
|
||||
}
|
||||
if !injected.Load() || err==nil {t.Fatal("stale writer was not rejected")}
|
||||
current:=ChannelService.Get(channel.ID)
|
||||
cfg,parseErr:=ChannelService.ParseWebChannelConfig(current.ConfigJSON)
|
||||
if parseErr!=nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret!="test-new-secret" {t.Fatal("stale writer replaced the concurrent policy/secret")}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelConfigCompareAndSwapUsesExactBytes(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
channel.ConfigJSON = `{"userTokenSecret":"CaseSensitive"}`
|
||||
if err := db.Model(channel).Updates(map[string]any{"config_json": channel.ConfigJSON, "updated_at": time.Now()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := *channel
|
||||
if err := db.Model(channel).UpdateColumn("config_json", `{"userTokenSecret":"casesensitive"}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := repositories.ChannelRepository.UpdatesConfigIfUnchanged(db, &expected, map[string]any{"config_json": `{"requireSignedIdentity":false}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated {
|
||||
t.Fatal("case-sensitive secret change was not treated as a conflict")
|
||||
}
|
||||
db,channel:=setupIdentitySecurityDB(t)
|
||||
channel.ConfigJSON=`{"userTokenSecret":"CaseSensitive"}`
|
||||
if err:=db.Model(channel).Updates(map[string]any{"config_json":channel.ConfigJSON,"updated_at":time.Now()}).Error;err!=nil{t.Fatal(err)}
|
||||
expected:=*channel
|
||||
if err:=db.Model(channel).UpdateColumn("config_json",`{"userTokenSecret":"casesensitive"}`).Error;err!=nil{t.Fatal(err)}
|
||||
updated,err:=repositories.ChannelRepository.UpdatesConfigIfUnchanged(db,&expected,map[string]any{"config_json":`{"requireSignedIdentity":false}`})
|
||||
if err!=nil{t.Fatal(err)}
|
||||
if updated{t.Fatal("case-sensitive secret change was not treated as a conflict")}
|
||||
}
|
||||
|
||||
func TestChannelConfigCompareAndSwapAllowsCurrentEditorAndReset(t *testing.T) {
|
||||
previousDB := sqls.DB()
|
||||
db := setupChannelServiceTestDB(t)
|
||||
t.Cleanup(func() { sqls.SetDB(previousDB) })
|
||||
agent := createChannelServiceTestAgent(t, db, 1001)
|
||||
channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Current policy", Status: int(enums.StatusOk), ConfigJSON: `{"requireSignedIdentity":true}`}, channelServiceTestOperator())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = ChannelService.UpdateChannel(request.UpdateChannelRequest{ID: channel.ID, CreateChannelRequest: request.CreateChannelRequest{ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "Current policy edited", Status: int(enums.StatusOk), ConfigJSON: `{"title":"Updated title"}`}}, channelServiceTestOperator())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, err := ChannelService.ResetUserTokenSecret(channel.ID, channelServiceTestOperator())
|
||||
if err != nil || secret == "" {
|
||||
t.Fatal("current secret reset failed")
|
||||
}
|
||||
cfg, err := ChannelService.ParseWebChannelConfig(ChannelService.Get(channel.ID).ConfigJSON)
|
||||
if err != nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret != secret {
|
||||
t.Fatal("current update/reset lost policy or returned unsaved secret")
|
||||
}
|
||||
previousDB:=sqls.DB()
|
||||
db:=setupChannelServiceTestDB(t)
|
||||
t.Cleanup(func(){sqls.SetDB(previousDB)})
|
||||
agent:=createChannelServiceTestAgent(t,db,1001)
|
||||
channel,err:=ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Current policy",Status:int(enums.StatusOk),ConfigJSON:`{"requireSignedIdentity":true}`},channelServiceTestOperator())
|
||||
if err!=nil{t.Fatal(err)}
|
||||
err=ChannelService.UpdateChannel(request.UpdateChannelRequest{ID:channel.ID,CreateChannelRequest:request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Current policy edited",Status:int(enums.StatusOk),ConfigJSON:`{"title":"Updated title"}`}},channelServiceTestOperator())
|
||||
if err!=nil{t.Fatal(err)}
|
||||
secret,err:=ChannelService.ResetUserTokenSecret(channel.ID,channelServiceTestOperator())
|
||||
if err!=nil || secret==""{t.Fatal("current secret reset failed")}
|
||||
cfg,err:=ChannelService.ParseWebChannelConfig(ChannelService.Get(channel.ID).ConfigJSON)
|
||||
if err!=nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret!=secret{t.Fatal("current update/reset lost policy or returned unsaved secret")}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,7 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
|
||||
// Older dashboard clients do not expose this policy yet. Omitting the field
|
||||
// during an unrelated edit must not silently turn authenticated-only off.
|
||||
merged, err := s.preserveWebIdentityPolicy(req.ConfigJSON, current.ConfigJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil { return err }
|
||||
req.ConfigJSON = merged
|
||||
}
|
||||
item, err := s.buildChannelModel(req.ID, req.CreateChannelRequest)
|
||||
@@ -208,9 +206,7 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi
|
||||
return nil, err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil { return nil, err }
|
||||
if policy, ok := fields["requireSignedIdentity"]; ok && string(policy) != "true" && string(policy) != "false" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0060")
|
||||
}
|
||||
@@ -705,12 +701,8 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
|
||||
// RequiresSignedIdentity enforces the channel policy on the server, not only in
|
||||
// the embedding SDK. Invalid web configuration fails closed.
|
||||
func (s *channelService) RequiresSignedIdentity(channel *models.Channel) bool {
|
||||
if channel == nil {
|
||||
return true
|
||||
}
|
||||
if channel.ChannelType != enums.ChannelTypeWeb {
|
||||
return false
|
||||
}
|
||||
if channel == nil { return true }
|
||||
if channel.ChannelType != enums.ChannelTypeWeb { return false }
|
||||
cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON)
|
||||
return err != nil || cfg.RequireSignedIdentity
|
||||
}
|
||||
@@ -718,23 +710,13 @@ func (s *channelService) RequiresSignedIdentity(channel *models.Channel) bool {
|
||||
func (s *channelService) preserveWebIdentityPolicy(raw, previousRaw string) (string, error) {
|
||||
fields := map[string]json.RawMessage{}
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return "", errorsx.InvalidParamI18n("error.e0060")
|
||||
}
|
||||
}
|
||||
if _, present := fields["requireSignedIdentity"]; present {
|
||||
return raw, nil
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil { return "", errorsx.InvalidParamI18n("error.e0060") }
|
||||
}
|
||||
if _, present := fields["requireSignedIdentity"]; present { return raw, nil }
|
||||
previous, err := s.ParseWebChannelConfig(previousRaw)
|
||||
if err != nil {
|
||||
return "", errorsx.InvalidParamI18n("error.e0060")
|
||||
}
|
||||
if !previous.RequireSignedIdentity {
|
||||
return raw, nil
|
||||
}
|
||||
if fields == nil {
|
||||
fields = map[string]json.RawMessage{}
|
||||
}
|
||||
if err != nil { return "", errorsx.InvalidParamI18n("error.e0060") }
|
||||
if !previous.RequireSignedIdentity { return raw, nil }
|
||||
if fields == nil { fields = map[string]json.RawMessage{} }
|
||||
fields["requireSignedIdentity"] = json.RawMessage("true")
|
||||
encoded, err := json.Marshal(fields)
|
||||
return string(encoded), err
|
||||
@@ -744,11 +726,7 @@ func (s *channelService) preserveWebIdentityPolicy(raw, previousRaw string) (str
|
||||
// an old explicit field into authority to overwrite the new policy/secret.
|
||||
func (s *channelService) updateConfigIfUnchanged(expected *models.Channel, columns map[string]any) error {
|
||||
updated, err := repositories.ChannelRepository.UpdatesConfigIfUnchanged(sqls.DB(), expected, columns)
|
||||
if err != nil {
|
||||
return errorsx.BusinessErrorI18n(0, "error.channel.configUpdateFailed")
|
||||
}
|
||||
if !updated {
|
||||
return errorsx.InvalidParamI18n("error.channel.configChanged")
|
||||
}
|
||||
if err != nil { return errorsx.BusinessErrorI18n(0, "error.channel.configUpdateFailed") }
|
||||
if !updated { return errorsx.InvalidParamI18n("error.channel.configChanged") }
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
package services
|
||||
|
||||
@@ -201,7 +200,6 @@ func (s *conversationDispatchService) RunPendingDispatchLoop(interval time.Durat
|
||||
)
|
||||
|
||||
for {
|
||||
_ = repositories.SupportAvailabilityRepository.PrunePresence(sqls.DB(), time.Now())
|
||||
if _, err := s.DispatchPendingConversations(0); err != nil {
|
||||
slog.Warn("dispatch pending conversations loop failed", "error", err)
|
||||
}
|
||||
@@ -216,29 +214,50 @@ func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, no
|
||||
RequestedTeamIDs: append([]int64(nil), teamIDs...),
|
||||
}
|
||||
|
||||
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs)
|
||||
if err != nil {
|
||||
return nil, report, err
|
||||
// 1. filter teams with active schedule
|
||||
activeTeamIDs := s.findActiveScheduleTeamIDs(teamIDs, now)
|
||||
report.ActiveScheduleTeams = activeTeamIDs
|
||||
if len(activeTeamIDs) == 0 {
|
||||
report.Reason = "no_active_schedule_team"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
// 2. find agent profiles for the active teams
|
||||
profiles := AgentProfileService.GetDispatchAgents(activeTeamIDs)
|
||||
report.MatchedProfiles = len(profiles)
|
||||
report.ActiveScheduleTeams = s.findActiveScheduleTeamIDs(teamIDs, now)
|
||||
candidates := make([]dispatchCandidate, 0, len(profiles))
|
||||
report.Reason = "no_eligible_operator"
|
||||
for _, profile := range profiles {
|
||||
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), profile, true, now, 0)
|
||||
if len(profiles) == 0 {
|
||||
report.Reason = "no_matched_profile"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
enabledProfiles, enabledUserIDs, reason := s.filterEnabledDispatchProfiles(profiles)
|
||||
if reason != "" {
|
||||
report.Reason = reason
|
||||
return nil, report, nil
|
||||
}
|
||||
report.EligibleProfiles = len(enabledProfiles)
|
||||
|
||||
activeCounts, err := s.findActiveConversationCountMap(enabledUserIDs)
|
||||
if err != nil {
|
||||
return nil, report, err
|
||||
}
|
||||
if !eligibility.Eligible {
|
||||
report.Reason = eligibility.Reason
|
||||
|
||||
candidates := make([]dispatchCandidate, 0, len(enabledProfiles))
|
||||
for _, profile := range enabledProfiles {
|
||||
activeCount := activeCounts[profile.UserID]
|
||||
if profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount {
|
||||
continue
|
||||
}
|
||||
report.EligibleProfiles++
|
||||
loadRate := float64(eligibility.ActiveCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
|
||||
candidates = append(candidates, dispatchCandidate{profile: profile, activeCount: eligibility.ActiveCount, loadRate: loadRate})
|
||||
loadRate := float64(activeCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
|
||||
candidates = append(candidates, dispatchCandidate{
|
||||
profile: profile,
|
||||
activeCount: activeCount,
|
||||
loadRate: loadRate,
|
||||
})
|
||||
}
|
||||
report.CandidateCount = len(candidates)
|
||||
if len(candidates) == 0 {
|
||||
report.Reason = "all_candidates_at_capacity"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
@@ -282,20 +301,86 @@ func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, no
|
||||
return candidates, report, nil
|
||||
}
|
||||
|
||||
// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time.
|
||||
func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 {
|
||||
result := make([]int64, 0, len(teamIDs))
|
||||
for _, teamID := range uniquePositiveInt64s(teamIDs) {
|
||||
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), teamID)
|
||||
if err != nil || team == nil || team.Status != enums.StatusOk {
|
||||
func (s *conversationDispatchService) filterEnabledDispatchProfiles(profiles []models.AgentProfile) ([]models.AgentProfile, []int64, string) {
|
||||
userIDs := make([]int64, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
if profile.UserID > 0 {
|
||||
userIDs = append(userIDs, profile.UserID)
|
||||
}
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil, "no_profile_with_capacity_config"
|
||||
}
|
||||
|
||||
enabledUsers := UserService.Find(sqls.NewCnd().
|
||||
In("id", userIDs).
|
||||
Eq("status", enums.StatusOk))
|
||||
if len(enabledUsers) == 0 {
|
||||
return nil, nil, "no_enabled_user"
|
||||
}
|
||||
|
||||
enabledUserSet := make(map[int64]struct{}, len(enabledUsers))
|
||||
for _, user := range enabledUsers {
|
||||
enabledUserSet[user.ID] = struct{}{}
|
||||
}
|
||||
|
||||
enabledProfiles := make([]models.AgentProfile, 0, len(profiles))
|
||||
enabledUserIDs := make([]int64, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
if _, exists := enabledUserSet[profile.UserID]; !exists {
|
||||
continue
|
||||
}
|
||||
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), teamID, now)
|
||||
if err == nil && calendar.Open {
|
||||
result = append(result, teamID)
|
||||
enabledProfiles = append(enabledProfiles, profile)
|
||||
enabledUserIDs = append(enabledUserIDs, profile.UserID)
|
||||
}
|
||||
if len(enabledProfiles) == 0 {
|
||||
return nil, nil, "no_profile_for_enabled_user"
|
||||
}
|
||||
return result
|
||||
return enabledProfiles, enabledUserIDs, ""
|
||||
}
|
||||
|
||||
// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time.
|
||||
func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 {
|
||||
if len(teamIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
teams := AgentTeamService.Find(sqls.NewCnd().
|
||||
In("id", teamIDs).
|
||||
Eq("status", enums.StatusOk))
|
||||
if len(teams) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabledTeamIDs := make([]int64, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
enabledTeamIDs = append(enabledTeamIDs, team.ID)
|
||||
}
|
||||
|
||||
schedules := AgentTeamScheduleService.Find(sqls.NewCnd().
|
||||
In("team_id", enabledTeamIDs).
|
||||
Eq("status", enums.StatusOk).
|
||||
Lte("start_at", now).
|
||||
Gt("end_at", now))
|
||||
|
||||
activeSet := make(map[int64]struct{}, len(schedules))
|
||||
for _, schedule := range schedules {
|
||||
activeSet[schedule.TeamID] = struct{}{}
|
||||
}
|
||||
|
||||
ret := make([]int64, 0, len(teamIDs))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, teamID := range teamIDs {
|
||||
if _, active := activeSet[teamID]; !active {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[teamID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[teamID] = struct{}{}
|
||||
ret = append(ret, teamID)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
|
||||
@@ -327,10 +412,6 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
operator := systemDispatchPrincipal()
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, candidate.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
return errConversationDispatchConflict
|
||||
@@ -339,12 +420,6 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
return errConversationDispatchConflict
|
||||
}
|
||||
|
||||
guarded, guardErr := SupportAvailabilityService.GuardAssignment(ctx.Tx, candidate.UserID, true, conversation, time.Now())
|
||||
if guardErr != nil {
|
||||
return guardErr
|
||||
}
|
||||
candidate = *guarded
|
||||
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -371,9 +446,6 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeAssign, enums.IMSenderTypeSystem, operator.UserID, "会话已自动分配", buildDispatchEventPayload(conversation.CurrentAssigneeID, candidate.UserID, candidate.TeamID, reason))
|
||||
})
|
||||
if errors.Is(err, errSupportAssignmentIneligible) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package services
|
||||
@@ -9,11 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/events"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/eventbus"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
@@ -159,7 +155,6 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
|
||||
&models.AgentTeam{},
|
||||
&models.AgentTeamSchedule{},
|
||||
&models.AgentProfile{},
|
||||
&models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationParticipant{},
|
||||
&models.ConversationAssignment{},
|
||||
@@ -172,9 +167,6 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
t.Cleanup(func() {
|
||||
// Assignment notifications run asynchronously. Join them before replacing
|
||||
// the global fixture DB/service or closing its SQLite connection.
|
||||
eventbus.Get[events.ConversationAssignedEvent]().Wait()
|
||||
sqls.SetDB(previousDB)
|
||||
WsService = previousWs
|
||||
})
|
||||
@@ -222,7 +214,6 @@ func createHumanDispatchRealtimeAgentProfile(t *testing.T, db *gorm.DB, userID,
|
||||
Username: "agent",
|
||||
Nickname: "客服",
|
||||
Status: enums.StatusOk,
|
||||
UserType: enums.UserTypeEmployee,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create user error = %v", err)
|
||||
}
|
||||
@@ -238,25 +229,6 @@ func createHumanDispatchRealtimeAgentProfile(t *testing.T, db *gorm.DB, userID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create profile error = %v", err)
|
||||
}
|
||||
role := models.Role{Code: "test-live-agent", Status: enums.StatusOk}
|
||||
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
|
||||
session := models.LoginSession{UserID: userID, Token: "ak_isolated_routing_fixture", ExpiredAt: time.Now().Add(time.Hour)}
|
||||
// The observing WebSocket already seeds this shared permission. Reuse it
|
||||
// without changing its state, while the assigned operator gets its own role.
|
||||
if err := db.Where("code = ?", permission.Code).FirstOrCreate(&permission).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range []any{&role, &session} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, item := range []any{&models.UserRole{UserID: userID, RoleID: role.ID}, &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, &models.AgentPresenceLease{ID: "isolated-routing-presence", UserID: userID, LoginSessionID: session.ID, ExpiresAt: time.Now().Add(time.Minute)}} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createHumanDispatchRealtimeConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
package services_test
|
||||
|
||||
import (
|
||||
@@ -7,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/openidentity"
|
||||
@@ -262,7 +260,6 @@ func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB {
|
||||
&models.AgentTeam{},
|
||||
&models.AgentTeamSchedule{},
|
||||
&models.AgentProfile{},
|
||||
&models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationParticipant{},
|
||||
&models.ConversationAssignment{},
|
||||
@@ -318,7 +315,6 @@ func createHumanDispatchAgentProfile(t *testing.T, db *gorm.DB, userID, teamID i
|
||||
Username: "agent",
|
||||
Nickname: "客服",
|
||||
Status: enums.StatusOk,
|
||||
UserType: enums.UserTypeEmployee,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create user error = %v", err)
|
||||
}
|
||||
@@ -334,20 +330,6 @@ func createHumanDispatchAgentProfile(t *testing.T, db *gorm.DB, userID, teamID i
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create profile error = %v", err)
|
||||
}
|
||||
role := models.Role{Code: "test-live-agent", Status: enums.StatusOk}
|
||||
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
|
||||
session := models.LoginSession{UserID: userID, Token: "ak_isolated_routing_fixture", ExpiredAt: time.Now().Add(time.Hour)}
|
||||
for _, item := range []any{&role, &permission, &session} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, item := range []any{&models.UserRole{UserID: userID, RoleID: role.ID}, &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, &models.AgentPresenceLease{ID: "isolated-routing-presence", UserID: userID, LoginSessionID: session.ID, ExpiresAt: time.Now().Add(time.Minute)}} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createHumanDispatchConversation(t *testing.T, db *gorm.DB, aiAgentID int64, status enums.IMConversationStatus) models.Conversation {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package services
|
||||
@@ -327,9 +326,7 @@ func (s *conversationService) ListCustomerConversations(externalUser openidentit
|
||||
cnd = sqls.NewCnd()
|
||||
}
|
||||
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID))
|
||||
if err != nil {
|
||||
return nil, nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
if err != nil { return nil, nil, errorsx.UnauthorizedI18n("error.e0161") }
|
||||
if identity == nil {
|
||||
paging := &sqls.Paging{}
|
||||
if cnd.Paging != nil {
|
||||
@@ -338,9 +335,7 @@ func (s *conversationService) ListCustomerConversations(externalUser openidentit
|
||||
return []models.Conversation{}, paging, nil
|
||||
}
|
||||
cnd.Eq("customer_id", identity.CustomerID).Desc("last_active_at").Desc("id")
|
||||
if externalUser.ChannelID > 0 {
|
||||
cnd.Eq("channel_id", externalUser.ChannelID)
|
||||
}
|
||||
if externalUser.ChannelID > 0 { cnd.Eq("channel_id", externalUser.ChannelID) }
|
||||
list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
if list == nil {
|
||||
list = []models.Conversation{}
|
||||
@@ -358,10 +353,6 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
}
|
||||
var assignedEvent events.ConversationAssignedEvent
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, req.AssigneeID); err != nil {
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
|
||||
}
|
||||
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0116")
|
||||
@@ -370,23 +361,14 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
return errorsx.InvalidParamI18n("error.e0135")
|
||||
}
|
||||
now := time.Now()
|
||||
targetProfile, err := SupportAvailabilityService.GuardAssignment(ctx.Tx, req.AssigneeID, false, conversation, now)
|
||||
if err != nil {
|
||||
if err == errSupportAssignmentIneligible {
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, enums.IMAssignmentTypeAssign, req.Reason, operator, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.SupportAvailabilityRepository.AssignConversation(ctx.Tx, conversation, map[string]any{
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
|
||||
"current_assignee_id": req.AssigneeID,
|
||||
"current_team_id": targetProfile.TeamID,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
@@ -465,10 +447,6 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
|
||||
}
|
||||
var assignedEvent events.ConversationAssignedEvent
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, toUserID); err != nil {
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
|
||||
}
|
||||
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0116")
|
||||
@@ -486,23 +464,14 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
|
||||
return errorsx.InvalidParamI18n("error.e0277")
|
||||
}
|
||||
now := time.Now()
|
||||
targetProfile, err := SupportAvailabilityService.GuardAssignment(ctx.Tx, toUserID, false, conversation, now)
|
||||
if err != nil {
|
||||
if err == errSupportAssignmentIneligible {
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, toUserID, enums.IMAssignmentTypeTransfer, reason, operator, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.SupportAvailabilityRepository.AssignConversation(ctx.Tx, conversation, map[string]any{
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
||||
"current_assignee_id": toUserID,
|
||||
"current_team_id": targetProfile.TeamID,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
@@ -863,9 +832,7 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
|
||||
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
|
||||
return false
|
||||
}
|
||||
if externalUser.ChannelID > 0 && externalUser.ChannelID != conversation.ChannelID {
|
||||
return false
|
||||
}
|
||||
if externalUser.ChannelID > 0 && externalUser.ChannelID != conversation.ChannelID { return false }
|
||||
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, extID)
|
||||
if err != nil || identity == nil {
|
||||
return false
|
||||
|
||||
@@ -31,33 +31,20 @@ func setupIdentitySecurityDB(t *testing.T) (*gorm.DB, *models.Channel) {
|
||||
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Channel{}, &models.Conversation{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Channel{}, &models.Conversation{}); err != nil { t.Fatal(err) }
|
||||
previousDB, previousConfig := sqls.DB(), config.GetCurrent()
|
||||
sqls.SetDB(db)
|
||||
config.SetCurrent(&config.Config{CustomerSession: config.CustomerSessionConfig{Secret: "test-only-customer-session-key", TTLMinutes: 120}})
|
||||
t.Cleanup(func() {
|
||||
sqls.SetDB(previousDB)
|
||||
config.SetCurrent(previousConfig)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
t.Cleanup(func() { sqls.SetDB(previousDB); config.SetCurrent(previousConfig); sqlDB, _ := db.DB(); _ = sqlDB.Close() })
|
||||
channel := &models.Channel{ChannelID: "channel-a", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"}
|
||||
if err := db.Create(channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(channel).Error; err != nil { t.Fatal(err) }
|
||||
return db, channel
|
||||
}
|
||||
|
||||
func scopedTestExternal(channel *models.Channel, issuer, id string) openidentity.ExternalUser {
|
||||
source := enums.ExternalSourceUser
|
||||
if issuer == "guest" {
|
||||
source = enums.ExternalSourceGuest
|
||||
}
|
||||
if issuer == "guest" { source = enums.ExternalSourceGuest }
|
||||
return openidentity.ExternalUser{ExternalSource: source, ExternalID: id, IdentityScope: openidentity.IdentityScope(channel.ChannelID, issuer), ChannelID: channel.ID}
|
||||
}
|
||||
|
||||
@@ -65,156 +52,93 @@ func verifySecurityCustomerSession(t *testing.T, channel *models.Channel, token
|
||||
t.Helper()
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("GET", "/api/ws/open", nil)
|
||||
ctx.Request.Header.Set("Authorization", "Bearer "+token)
|
||||
ctx.Request.Header.Set("Authorization", "Bearer " + token)
|
||||
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
|
||||
if err != nil {
|
||||
t.Fatalf("verify customer credential: %v", err)
|
||||
}
|
||||
if err != nil { t.Fatalf("verify customer credential: %v", err) }
|
||||
return result
|
||||
}
|
||||
|
||||
func TestCustomerIdentitySeparatesTrustIssuerAndChannel(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
otherChannel := &models.Channel{ChannelID: "channel-b", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"}
|
||||
if err := db.Create(otherChannel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(otherChannel).Error; err != nil { t.Fatal(err) }
|
||||
customerIDs := map[int64]bool{}
|
||||
for _, input := range []struct {
|
||||
channel *models.Channel
|
||||
issuer string
|
||||
}{{channel, "hmac"}, {channel, "jwt"}, {channel, "guest"}, {otherChannel, "hmac"}} {
|
||||
for _, input := range []struct{ channel *models.Channel; issuer string }{{channel,"hmac"},{channel,"jwt"},{channel,"guest"},{otherChannel,"hmac"}} {
|
||||
exchanged, err := CustomerSessionService.Exchange(input.channel, scopedTestExternal(input.channel, input.issuer, "same-id"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if customerIDs[exchanged.Customer.ID] {
|
||||
t.Fatal("independent identity scopes were merged")
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if customerIDs[exchanged.Customer.ID] { t.Fatal("independent identity scopes were merged") }
|
||||
customerIDs[exchanged.Customer.ID] = true
|
||||
result := verifySecurityCustomerSession(t, input.channel, exchanged.CustomerSessionToken)
|
||||
if !CustomerSessionService.VerifyConnection(result) {
|
||||
t.Fatal("fresh scoped connection rejected")
|
||||
}
|
||||
if !CustomerSessionService.VerifyConnection(result) { t.Fatal("fresh scoped connection rejected") }
|
||||
wrongChannel := &models.Conversation{CustomerID: result.CustomerID, ChannelID: otherChannel.ID}
|
||||
if input.channel.ID != otherChannel.ID && ConversationService.IsCustomerConversationOwner(wrongChannel, *result.ExternalUser) {
|
||||
t.Fatal("customer acquired a different channel's conversation")
|
||||
}
|
||||
if input.channel.ID != otherChannel.ID && ConversationService.IsCustomerConversationOwner(wrongChannel, *result.ExternalUser) { t.Fatal("customer acquired a different channel's conversation") }
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBootstrapCannotReclaimKnownAnonymousIdentity(t *testing.T) {
|
||||
_, channel := setupIdentitySecurityDB(t)
|
||||
first, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "caller-chosen-id"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stolenID := strings.TrimPrefix(first.IdentityKey, "guest:")
|
||||
if len(stolenID) != 64 || stolenID == "caller-chosen-id" {
|
||||
t.Fatal("guest identity was not server-generated")
|
||||
}
|
||||
second, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", stolenID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Customer.ID == second.Customer.ID || first.IdentityKey == second.IdentityKey {
|
||||
t.Fatal("public guest bootstrap reclaimed another guest identity")
|
||||
}
|
||||
original := verifySecurityCustomerSession(t, channel, first.CustomerSessionToken)
|
||||
if original.CustomerID != first.Customer.ID {
|
||||
t.Fatal("signed guest credential did not retain its owner")
|
||||
}
|
||||
first, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel,"guest","caller-chosen-id"))
|
||||
if err != nil { t.Fatal(err) }
|
||||
stolenID := strings.TrimPrefix(first.IdentityKey,"guest:")
|
||||
if len(stolenID) != 64 || stolenID == "caller-chosen-id" { t.Fatal("guest identity was not server-generated") }
|
||||
second, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel,"guest",stolenID))
|
||||
if err != nil { t.Fatal(err) }
|
||||
if first.Customer.ID == second.Customer.ID || first.IdentityKey == second.IdentityKey { t.Fatal("public guest bootstrap reclaimed another guest identity") }
|
||||
original := verifySecurityCustomerSession(t,channel,first.CustomerSessionToken)
|
||||
if original.CustomerID != first.Customer.ID { t.Fatal("signed guest credential did not retain its owner") }
|
||||
}
|
||||
|
||||
func TestCustomerIdentityDuplicatesFailClosedWithoutCreatingAnotherCustomer(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
external := scopedTestExternal(channel, "hmac", "duplicate")
|
||||
for i := 0; i < 2; i++ {
|
||||
external := scopedTestExternal(channel,"hmac","duplicate")
|
||||
for i:=0; i<2; i++ {
|
||||
customer := &models.Customer{Status: enums.StatusOk}
|
||||
if err := db.Create(customer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(customer).Error; err != nil { t.Fatal(err) }
|
||||
identity := &models.CustomerIdentity{CustomerID: customer.ID, IdentityScope: external.IdentityScope, ExternalSource: external.ExternalSource, ExternalID: external.ExternalID, Status: enums.StatusOk}
|
||||
if err := db.Create(identity).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := CustomerSessionService.Exchange(channel, external); err == nil {
|
||||
t.Fatal("ambiguous identity was accepted")
|
||||
if err := db.Create(identity).Error; err != nil { t.Fatal(err) }
|
||||
}
|
||||
if _, err := CustomerSessionService.Exchange(channel,external); err == nil { t.Fatal("ambiguous identity was accepted") }
|
||||
var count int64
|
||||
if err := db.Model(&models.Customer{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("ambiguous mapping created another customer: %d", count)
|
||||
}
|
||||
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(db, external.IdentityScope, external.ExternalSource, external.ExternalID)
|
||||
if err == nil || identity != nil {
|
||||
t.Fatal("duplicate lookup did not fail closed")
|
||||
}
|
||||
if err := db.Model(&models.Customer{}).Count(&count).Error; err != nil { t.Fatal(err) }
|
||||
if count != 2 { t.Fatalf("ambiguous mapping created another customer: %d",count) }
|
||||
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(db,external.IdentityScope,external.ExternalSource,external.ExternalID)
|
||||
if err == nil || identity != nil { t.Fatal("duplicate lookup did not fail closed") }
|
||||
}
|
||||
|
||||
func TestCustomerSessionsRejectLegacyScopeAndLivePolicyChanges(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "guest"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := verifySecurityCustomerSession(t, channel, exchanged.CustomerSessionToken)
|
||||
exchanged, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","guest"))
|
||||
if err != nil { t.Fatal(err) }
|
||||
current := verifySecurityCustomerSession(t,channel,exchanged.CustomerSessionToken)
|
||||
claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
claims.IdentityScope = ""
|
||||
legacy, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CustomerSessionService.verifyToken(legacy); err == nil {
|
||||
t.Fatal("unscoped legacy credential accepted")
|
||||
}
|
||||
if err := db.Model(channel).Update("config_json", `{"requireSignedIdentity":true}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if CustomerSessionService.VerifyConnection(current) {
|
||||
t.Fatal("existing guest retained access after signed-only policy change")
|
||||
}
|
||||
channel.ConfigJSON = `{"requireSignedIdentity":true}`
|
||||
if _, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "guest")); err == nil {
|
||||
t.Fatal("signed-only channel accepted guest exchange")
|
||||
}
|
||||
legacy, err := jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret))
|
||||
if err != nil { t.Fatal(err) }
|
||||
if _, err := CustomerSessionService.verifyToken(legacy); err == nil { t.Fatal("unscoped legacy credential accepted") }
|
||||
if err := db.Model(channel).Update("config_json",`{"requireSignedIdentity":true}`).Error; err != nil { t.Fatal(err) }
|
||||
if CustomerSessionService.VerifyConnection(current) { t.Fatal("existing guest retained access after signed-only policy change") }
|
||||
channel.ConfigJSON=`{"requireSignedIdentity":true}`
|
||||
if _, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","guest")); err == nil { t.Fatal("signed-only channel accepted guest exchange") }
|
||||
}
|
||||
|
||||
func TestCustomerConnectionRejectsExpiryDisabledCustomerAndIdentityChanges(t *testing.T) {
|
||||
for _, scenario := range []string{"expiry", "disabled-customer", "disabled-channel", "disabled-identity", "rebound-identity", "duplicate-identity"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
for _, scenario := range []string{"expiry","disabled-customer","disabled-channel","disabled-identity","rebound-identity","duplicate-identity"} {
|
||||
t.Run(scenario,func(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "hmac", "signed"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := verifySecurityCustomerSession(t, channel, exchanged.CustomerSessionToken)
|
||||
exchanged, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"hmac","signed"))
|
||||
if err != nil { t.Fatal(err) }
|
||||
current := verifySecurityCustomerSession(t,channel,exchanged.CustomerSessionToken)
|
||||
switch scenario {
|
||||
case "expiry":
|
||||
current.ExpiresAt = time.Now()
|
||||
case "disabled-customer":
|
||||
err = db.Model(&models.Customer{}).Where("id = ?", current.CustomerID).Update("status", enums.StatusDisabled).Error
|
||||
case "disabled-channel":
|
||||
err = db.Model(channel).Update("status", enums.StatusDisabled).Error
|
||||
case "disabled-identity":
|
||||
err = db.Model(&models.CustomerIdentity{}).Where("customer_id = ?", current.CustomerID).Update("status", enums.StatusDisabled).Error
|
||||
case "rebound-identity":
|
||||
err = db.Model(&models.CustomerIdentity{}).Where("customer_id = ?", current.CustomerID).Update("customer_id", current.CustomerID+100).Error
|
||||
case "duplicate-identity":
|
||||
err = db.Create(&models.CustomerIdentity{CustomerID: current.CustomerID + 100, ExternalSource: current.ExternalUser.ExternalSource, ExternalID: current.ExternalUser.ExternalID, IdentityScope: current.ExternalUser.IdentityScope, Status: enums.StatusOk}).Error
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if CustomerSessionService.VerifyConnection(current) {
|
||||
t.Fatal("invalidated customer connection retained authority")
|
||||
case "expiry": current.ExpiresAt=time.Now()
|
||||
case "disabled-customer": err=db.Model(&models.Customer{}).Where("id = ?",current.CustomerID).Update("status",enums.StatusDisabled).Error
|
||||
case "disabled-channel": err=db.Model(channel).Update("status",enums.StatusDisabled).Error
|
||||
case "disabled-identity": err=db.Model(&models.CustomerIdentity{}).Where("customer_id = ?",current.CustomerID).Update("status",enums.StatusDisabled).Error
|
||||
case "rebound-identity": err=db.Model(&models.CustomerIdentity{}).Where("customer_id = ?",current.CustomerID).Update("customer_id",current.CustomerID+100).Error
|
||||
case "duplicate-identity": err=db.Create(&models.CustomerIdentity{CustomerID:current.CustomerID+100,ExternalSource:current.ExternalUser.ExternalSource,ExternalID:current.ExternalUser.ExternalID,IdentityScope:current.ExternalUser.IdentityScope,Status:enums.StatusOk}).Error
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if CustomerSessionService.VerifyConnection(current) { t.Fatal("invalidated customer connection retained authority") }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -222,229 +146,127 @@ func TestCustomerConnectionRejectsExpiryDisabledCustomerAndIdentityChanges(t *te
|
||||
func TestCustomerRealtimeTopicsAndDeliveryRejectCrossScopeCollisions(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
ws := newWsService()
|
||||
signed, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "hmac", "known-id"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guest, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "known-id"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signedAuth := verifySecurityCustomerSession(t, channel, signed.CustomerSessionToken)
|
||||
guestAuth := verifySecurityCustomerSession(t, channel, guest.CustomerSessionToken)
|
||||
conversation := &models.Conversation{CustomerID: signed.Customer.ID, ChannelID: channel.ID}
|
||||
if err := db.Create(conversation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := &ClientSession{ID: "owner", Role: realtimeRoleUser, External: signedAuth.ExternalUser, CustomerSession: signedAuth, Topics: map[string]struct{}{}, Send: make(chan []byte, 4)}
|
||||
attacker := &ClientSession{ID: "guest", Role: realtimeRoleUser, External: guestAuth.ExternalUser, CustomerSession: guestAuth, Topics: map[string]struct{}{}, Send: make(chan []byte, 4)}
|
||||
if ws.customerTopic(signed.Customer.ID, channel.ID) == ws.customerTopic(guest.Customer.ID, channel.ID) {
|
||||
t.Fatal("trust namespaces collided")
|
||||
}
|
||||
if ws.customerTopic(signed.Customer.ID, channel.ID) == ws.customerTopic(signed.Customer.ID, channel.ID+1) {
|
||||
t.Fatal("channel namespaces collided")
|
||||
}
|
||||
ws.manager.Register(owner, ws.defaultTopics(owner))
|
||||
signed, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"hmac","known-id"))
|
||||
if err != nil { t.Fatal(err) }
|
||||
guest, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","known-id"))
|
||||
if err != nil { t.Fatal(err) }
|
||||
signedAuth:=verifySecurityCustomerSession(t,channel,signed.CustomerSessionToken)
|
||||
guestAuth:=verifySecurityCustomerSession(t,channel,guest.CustomerSessionToken)
|
||||
conversation:= &models.Conversation{CustomerID:signed.Customer.ID,ChannelID:channel.ID}
|
||||
if err:=db.Create(conversation).Error;err!=nil { t.Fatal(err) }
|
||||
owner:=&ClientSession{ID:"owner",Role:realtimeRoleUser,External:signedAuth.ExternalUser,CustomerSession:signedAuth,Topics:map[string]struct{}{},Send:make(chan []byte,4)}
|
||||
attacker:=&ClientSession{ID:"guest",Role:realtimeRoleUser,External:guestAuth.ExternalUser,CustomerSession:guestAuth,Topics:map[string]struct{}{},Send:make(chan []byte,4)}
|
||||
if ws.customerTopic(signed.Customer.ID,channel.ID)==ws.customerTopic(guest.Customer.ID,channel.ID) { t.Fatal("trust namespaces collided") }
|
||||
if ws.customerTopic(signed.Customer.ID,channel.ID)==ws.customerTopic(signed.Customer.ID,channel.ID+1) { t.Fatal("channel namespaces collided") }
|
||||
ws.manager.Register(owner,ws.defaultTopics(owner))
|
||||
// Defense in depth: even an accidentally stale/wrong topic registration may
|
||||
// not grant the guest authorization to the signed customer's conversation.
|
||||
ws.manager.Register(attacker, ws.routeConversationTopics(conversation))
|
||||
ws.PublishAgentActivity(conversation, "step", "", "running")
|
||||
select {
|
||||
case <-owner.Send:
|
||||
default:
|
||||
t.Fatal("owner did not receive its event")
|
||||
}
|
||||
select {
|
||||
case <-attacker.Send:
|
||||
t.Fatal("guest received another customer's event")
|
||||
default:
|
||||
}
|
||||
if ws.canSubscribeConversation(attacker, conversation.ID) {
|
||||
t.Fatal("explicit foreign conversation subscription allowed")
|
||||
}
|
||||
ws.manager.Register(attacker,ws.routeConversationTopics(conversation))
|
||||
ws.PublishAgentActivity(conversation,"step","","running")
|
||||
select { case <-owner.Send: default: t.Fatal("owner did not receive its event") }
|
||||
select { case <-attacker.Send: t.Fatal("guest received another customer's event"); default: }
|
||||
if ws.canSubscribeConversation(attacker,conversation.ID) { t.Fatal("explicit foreign conversation subscription allowed") }
|
||||
}
|
||||
|
||||
func TestScopedIdentityUniqueClaimPreservesLegacyRows(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
source := enums.ExternalSourceUser
|
||||
// Dirty legacy mappings remain migratable because their unique claim is NULL.
|
||||
for _, customerID := range []int64{100, 101} {
|
||||
if err := db.Create(&models.CustomerIdentity{CustomerID: customerID, ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope := openidentity.IdentityScope(channel.ChannelID, "hmac")
|
||||
first := &models.CustomerIdentity{CustomerID: 102, IdentityScope: scope, ScopedKey: repositories.ScopedCustomerIdentityKey(scope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}
|
||||
if err := db.Create(first).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
competing := &models.CustomerIdentity{CustomerID: 103, IdentityScope: scope, ScopedKey: repositories.ScopedCustomerIdentityKey(scope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}
|
||||
if err := db.Create(competing).Error; err == nil {
|
||||
t.Fatal("database allowed a competing scoped identity claim")
|
||||
}
|
||||
otherScope := openidentity.IdentityScope(channel.ChannelID, "jwt")
|
||||
if err := db.Create(&models.CustomerIdentity{CustomerID: 104, IdentityScope: otherScope, ScopedKey: repositories.ScopedCustomerIdentityKey(otherScope, source, "same"), ExternalSource: source, ExternalID: "same", Status: enums.StatusOk}).Error; err != nil {
|
||||
t.Fatal("different issuer's claim was conflated")
|
||||
}
|
||||
found, err := repositories.CustomerIdentityRepository.GetUniqueBy(db, scope, source, "same")
|
||||
if err != nil || found == nil || found.CustomerID != 102 {
|
||||
t.Fatal("legacy mappings interfered with scoped identity")
|
||||
for _, customerID := range []int64{100,101} {
|
||||
if err := db.Create(&models.CustomerIdentity{CustomerID:customerID,ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}).Error;err!=nil { t.Fatal(err) }
|
||||
}
|
||||
scope:=openidentity.IdentityScope(channel.ChannelID,"hmac")
|
||||
first:=&models.CustomerIdentity{CustomerID:102,IdentityScope:scope,ScopedKey:repositories.ScopedCustomerIdentityKey(scope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}
|
||||
if err:=db.Create(first).Error;err!=nil { t.Fatal(err) }
|
||||
competing:=&models.CustomerIdentity{CustomerID:103,IdentityScope:scope,ScopedKey:repositories.ScopedCustomerIdentityKey(scope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}
|
||||
if err:=db.Create(competing).Error;err==nil { t.Fatal("database allowed a competing scoped identity claim") }
|
||||
otherScope:=openidentity.IdentityScope(channel.ChannelID,"jwt")
|
||||
if err:=db.Create(&models.CustomerIdentity{CustomerID:104,IdentityScope:otherScope,ScopedKey:repositories.ScopedCustomerIdentityKey(otherScope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}).Error;err!=nil { t.Fatal("different issuer's claim was conflated") }
|
||||
found,err:=repositories.CustomerIdentityRepository.GetUniqueBy(db,scope,source,"same")
|
||||
if err!=nil || found==nil || found.CustomerID!=102 { t.Fatal("legacy mappings interfered with scoped identity") }
|
||||
}
|
||||
|
||||
func TestWebIdentityPolicySurvivesOlderClientEdits(t *testing.T) {
|
||||
svc := newChannelService()
|
||||
raw, err := svc.preserveWebIdentityPolicy(`{"title":"New title"}`, `{"requireSignedIdentity":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := svc.ParseWebChannelConfig(raw)
|
||||
if err != nil || !cfg.RequireSignedIdentity {
|
||||
t.Fatal("older client edit silently disabled signed-only policy")
|
||||
}
|
||||
raw, err = svc.preserveWebIdentityPolicy(`{"requireSignedIdentity":false}`, `{"requireSignedIdentity":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err = svc.ParseWebChannelConfig(raw)
|
||||
if err != nil || cfg.RequireSignedIdentity {
|
||||
t.Fatal("explicit policy change was not honored")
|
||||
}
|
||||
for _, raw := range []string{`{"requireSignedIdentity":null}`, `{"requireSignedIdentity":"false"}`, `{"requireSignedIdentity":0}`} {
|
||||
if _, err := svc.ParseWebChannelConfig(raw); err == nil {
|
||||
t.Fatal("non-boolean authentication policy accepted")
|
||||
}
|
||||
svc:=newChannelService()
|
||||
raw,err:=svc.preserveWebIdentityPolicy(`{"title":"New title"}`,`{"requireSignedIdentity":true}`)
|
||||
if err!=nil { t.Fatal(err) }
|
||||
cfg,err:=svc.ParseWebChannelConfig(raw)
|
||||
if err!=nil || !cfg.RequireSignedIdentity { t.Fatal("older client edit silently disabled signed-only policy") }
|
||||
raw,err=svc.preserveWebIdentityPolicy(`{"requireSignedIdentity":false}`,`{"requireSignedIdentity":true}`)
|
||||
if err!=nil { t.Fatal(err) }
|
||||
cfg,err=svc.ParseWebChannelConfig(raw)
|
||||
if err!=nil || cfg.RequireSignedIdentity { t.Fatal("explicit policy change was not honored") }
|
||||
for _,raw:=range []string{`{"requireSignedIdentity":null}`,`{"requireSignedIdentity":"false"}`,`{"requireSignedIdentity":0}`} {
|
||||
if _,err:=svc.ParseWebChannelConfig(raw);err==nil { t.Fatal("non-boolean authentication policy accepted") }
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerSessionNearExpiryRefreshPreservesIdentity(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
exchanged, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel, "guest", "local-context"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalKey, originalScope, originalCustomer := claims.IdentityKey, claims.IdentityScope, claims.CustomerID
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(20 * time.Second))
|
||||
nearExpiry, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refreshed := verifySecurityCustomerSession(t, channel, nearExpiry)
|
||||
if !refreshed.Refreshed || !refreshed.ExpiresAt.After(claims.ExpiresAt.Time) || refreshed.Token == nearExpiry {
|
||||
t.Fatal("near-expiry credential was not refreshed")
|
||||
}
|
||||
refreshedClaims, err := CustomerSessionService.verifyToken(refreshed.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if refreshedClaims.IdentityKey != originalKey || refreshedClaims.IdentityScope != originalScope || refreshedClaims.CustomerID != originalCustomer {
|
||||
t.Fatal("refresh changed anonymous identity")
|
||||
}
|
||||
db,channel:=setupIdentitySecurityDB(t)
|
||||
exchanged,err:=CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","local-context"))
|
||||
if err!=nil{t.Fatal(err)}
|
||||
claims,err:=CustomerSessionService.verifyToken(exchanged.CustomerSessionToken)
|
||||
if err!=nil{t.Fatal(err)}
|
||||
originalKey,originalScope,originalCustomer:=claims.IdentityKey,claims.IdentityScope,claims.CustomerID
|
||||
claims.ExpiresAt=jwt.NewNumericDate(time.Now().Add(20*time.Second))
|
||||
nearExpiry,err:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret))
|
||||
if err!=nil{t.Fatal(err)}
|
||||
refreshed:=verifySecurityCustomerSession(t,channel,nearExpiry)
|
||||
if !refreshed.Refreshed || !refreshed.ExpiresAt.After(claims.ExpiresAt.Time) || refreshed.Token==nearExpiry {t.Fatal("near-expiry credential was not refreshed")}
|
||||
refreshedClaims,err:=CustomerSessionService.verifyToken(refreshed.Token)
|
||||
if err!=nil{t.Fatal(err)}
|
||||
if refreshedClaims.IdentityKey!=originalKey || refreshedClaims.IdentityScope!=originalScope || refreshedClaims.CustomerID!=originalCustomer {t.Fatal("refresh changed anonymous identity")}
|
||||
var customers int64
|
||||
if err := db.Model(&models.Customer{}).Count(&customers).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if customers != 1 {
|
||||
t.Fatal("refresh allocated another customer")
|
||||
}
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Second))
|
||||
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Current().CustomerSession.Secret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/api/customer/session_refresh", nil)
|
||||
ctx.Request.Header.Set("Authorization", "Bearer "+expired)
|
||||
if _, err := CustomerSessionService.VerifyRequest(ctx, channel); err == nil {
|
||||
t.Fatal("expired anonymous credential was silently renewed")
|
||||
}
|
||||
if err:=db.Model(&models.Customer{}).Count(&customers).Error;err!=nil{t.Fatal(err)}
|
||||
if customers!=1{t.Fatal("refresh allocated another customer")}
|
||||
claims.ExpiresAt=jwt.NewNumericDate(time.Now().Add(-time.Second))
|
||||
expired,err:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret))
|
||||
if err!=nil{t.Fatal(err)}
|
||||
ctx,_:=gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request=httptest.NewRequest("POST","/api/customer/session_refresh",nil)
|
||||
ctx.Request.Header.Set("Authorization","Bearer "+expired)
|
||||
if _,err:=CustomerSessionService.VerifyRequest(ctx,channel);err==nil{t.Fatal("expired anonymous credential was silently renewed")}
|
||||
}
|
||||
|
||||
func TestConcurrentCustomerSessionExchangesKeepOneScopedOwner(t *testing.T) {
|
||||
db, channel := setupIdentitySecurityDB(t)
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db,channel:=setupIdentitySecurityDB(t)
|
||||
if err:=db.Exec("PRAGMA journal_mode=WAL").Error;err!=nil{t.Fatal(err)}
|
||||
pool,err:=db.DB();if err!=nil{t.Fatal(err)}
|
||||
pool.SetMaxOpenConns(4)
|
||||
external := scopedTestExternal(channel, "hmac", "concurrent-id")
|
||||
external:=scopedTestExternal(channel,"hmac","concurrent-id")
|
||||
var queries atomic.Int32
|
||||
arrived := make(chan struct{}, 2)
|
||||
release := make(chan struct{})
|
||||
arrived:=make(chan struct{},2)
|
||||
release:=make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
releaseBoth := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
releaseBoth:=func(){releaseOnce.Do(func(){close(release)})}
|
||||
var exchanges sync.WaitGroup
|
||||
defer func() { releaseBoth(); exchanges.Wait() }()
|
||||
callbackName := "test:concurrent_scoped_lookup"
|
||||
if err := db.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table != "t_customer_identity" || tx.Error != nil || tx.RowsAffected != 0 {
|
||||
return
|
||||
}
|
||||
if queries.Add(1) <= 2 {
|
||||
arrived <- struct{}{}
|
||||
<-release
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Query().Remove(callbackName) })
|
||||
type result struct {
|
||||
value *response.CustomerSessionExchangeResponse
|
||||
err error
|
||||
}
|
||||
results := make(chan result, 2)
|
||||
defer func(){releaseBoth();exchanges.Wait()}()
|
||||
callbackName:="test:concurrent_scoped_lookup"
|
||||
if err:=db.Callback().Query().After("gorm:query").Register(callbackName,func(tx *gorm.DB){
|
||||
if tx.Statement.Table!="t_customer_identity" || tx.Error!=nil || tx.RowsAffected!=0{return}
|
||||
if queries.Add(1)<=2{arrived<-struct{}{};<-release}
|
||||
});err!=nil{t.Fatal(err)}
|
||||
t.Cleanup(func(){_ = db.Callback().Query().Remove(callbackName)})
|
||||
type result struct{ value *response.CustomerSessionExchangeResponse; err error }
|
||||
results:=make(chan result,2)
|
||||
exchanges.Add(2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
defer exchanges.Done()
|
||||
value, err := CustomerSessionService.Exchange(channel, external)
|
||||
results <- result{value, err}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-arrived:
|
||||
case <-time.After(2 * time.Second):
|
||||
for i:=0;i<2;i++{go func(){defer exchanges.Done();value,err:=CustomerSessionService.Exchange(channel,external);results<-result{value,err}}()}
|
||||
for i:=0;i<2;i++{select{case <-arrived:case <-time.After(2*time.Second):releaseBoth();t.Fatal("concurrent transactions did not both reach initial identity lookup")}}
|
||||
releaseBoth()
|
||||
t.Fatal("concurrent transactions did not both reach initial identity lookup")
|
||||
}
|
||||
}
|
||||
releaseBoth()
|
||||
successes := 0
|
||||
successes:=0
|
||||
var owner int64
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case result := <-results:
|
||||
if result.err == nil {
|
||||
successes++
|
||||
owner = result.value.Customer.ID
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("concurrent exchange did not finish")
|
||||
for i:=0;i<2;i++{
|
||||
select{
|
||||
case result:=<-results:
|
||||
if result.err==nil{successes++;owner=result.value.Customer.ID}
|
||||
case <-time.After(2*time.Second):t.Fatal("concurrent exchange did not finish")
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("expected one transaction winner, got %d", successes)
|
||||
}
|
||||
var customers, identities int64
|
||||
if err := db.Model(&models.Customer{}).Count(&customers).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.CustomerIdentity{}).Count(&identities).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if customers != 1 || identities != 1 {
|
||||
t.Fatal("losing transaction left an orphan or competing customer mapping")
|
||||
}
|
||||
retry, err := CustomerSessionService.Exchange(channel, external)
|
||||
if err != nil || retry.Customer.ID != owner {
|
||||
t.Fatal("retry did not resolve the unique winning owner")
|
||||
}
|
||||
if successes!=1{t.Fatalf("expected one transaction winner, got %d",successes)}
|
||||
var customers,identities int64
|
||||
if err:=db.Model(&models.Customer{}).Count(&customers).Error;err!=nil{t.Fatal(err)}
|
||||
if err:=db.Model(&models.CustomerIdentity{}).Count(&identities).Error;err!=nil{t.Fatal(err)}
|
||||
if customers!=1 || identities!=1{t.Fatal("losing transaction left an orphan or competing customer mapping")}
|
||||
retry,err:=CustomerSessionService.Exchange(channel,external)
|
||||
if err!=nil || retry.Customer.ID!=owner{t.Fatal("retry did not resolve the unique winning owner")}
|
||||
}
|
||||
|
||||
@@ -127,14 +127,10 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs
|
||||
}
|
||||
now := time.Now()
|
||||
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(ctx.Tx, externalUser.IdentityScope, externalSource, externalID)
|
||||
if err != nil {
|
||||
return 0, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
if err != nil { return 0, errorsx.UnauthorizedI18n("error.e0161") }
|
||||
if identity != nil {
|
||||
customer := repositories.CustomerRepository.Get(ctx.Tx, identity.CustomerID)
|
||||
if customer == nil || customer.Status != enums.StatusOk {
|
||||
return 0, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
if customer == nil || customer.Status != enums.StatusOk { return 0, errorsx.UnauthorizedI18n("error.e0161") }
|
||||
updates := map[string]any{
|
||||
"last_active_at": now,
|
||||
"updated_at": now,
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -71,9 +71,7 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
|
||||
// A public caller's guest ID is not proof of possession. Only the signed
|
||||
// customer session can resume this server-issued anonymous identity.
|
||||
var id [32]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := rand.Read(id[:]); err != nil { return nil, err }
|
||||
externalUser.ExternalID = hex.EncodeToString(id[:])
|
||||
}
|
||||
var customerID int64
|
||||
@@ -92,9 +90,7 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
|
||||
return nil, errorsx.InvalidParamI18n("error.e0155")
|
||||
}
|
||||
identity, identityErr := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, externalUser.ExternalID)
|
||||
if identityErr != nil || identity == nil || identity.CustomerID != customer.ID {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
if identityErr != nil || identity == nil || identity.CustomerID != customer.ID { return nil, errorsx.UnauthorizedI18n("error.e0161") }
|
||||
token, expiresAt, err := s.Sign(channel, customer, externalUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -151,9 +147,7 @@ func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.verifyClaims(claims, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil { return nil, err }
|
||||
result.Token = token
|
||||
if s.shouldRefresh(claims.ExpiresAt.Time) {
|
||||
newToken, expiresAt, err := s.Sign(channel, CustomerService.Get(result.CustomerID), *result.ExternalUser)
|
||||
@@ -267,13 +261,9 @@ func (s *customerSessionService) getCustomerSessionToken(ctx *gin.Context) strin
|
||||
// VerifyConnection rechecks the signed credential, expiry, channel policy and
|
||||
// current identity mapping. It never refreshes an idle connection indefinitely.
|
||||
func (s *customerSessionService) VerifyConnection(result *CustomerSessionVerifyResult) bool {
|
||||
if result == nil || !time.Now().Before(result.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
if result == nil || !time.Now().Before(result.ExpiresAt) { return false }
|
||||
claims, err := s.verifyToken(result.Token)
|
||||
if err != nil || claims.CustomerID != result.CustomerID || claims.ChannelID != result.ChannelID {
|
||||
return false
|
||||
}
|
||||
if err != nil || claims.CustomerID != result.CustomerID || claims.ChannelID != result.ChannelID { return false }
|
||||
verified, err := s.verifyClaims(claims, ChannelService.Get(claims.ChannelID))
|
||||
return err == nil && verified.ExternalUser != nil && result.ExternalUser != nil &&
|
||||
verified.ExternalUser.IdentityScope == result.ExternalUser.IdentityScope &&
|
||||
@@ -286,13 +276,9 @@ func (s *customerSessionService) verifyClaims(claims *customerSessionClaims, cha
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
customer := CustomerService.Get(claims.CustomerID)
|
||||
if customer == nil || customer.Status != enums.StatusOk {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
if customer == nil || customer.Status != enums.StatusOk { return nil, errorsx.UnauthorizedI18n("error.e0161") }
|
||||
external, err := s.externalUserFromClaims(claims, customer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil { return nil, err }
|
||||
if !openidentity.ValidIdentityScope(channel.ChannelID, *external) || (ChannelService.RequiresSignedIdentity(channel) && external.ExternalSource != enums.ExternalSourceUser) {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -118,18 +117,11 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons
|
||||
}
|
||||
|
||||
func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTeam, profiles []models.AgentProfile, schedules []models.AgentTeamSchedule, conversations []models.Conversation) (int64, int64, int64, []response.DashboardTeamLoadResponse) {
|
||||
userIDs := make([]int64, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
userIDs = append(userIDs, profile.UserID)
|
||||
}
|
||||
online, _ := SupportAvailabilityService.onlineUsers(sqls.DB(), userIDs, now)
|
||||
const onlineWindow = 15 * time.Minute
|
||||
|
||||
scheduledTeamIDs := make(map[int64]bool, len(schedules))
|
||||
for _, team := range teams {
|
||||
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), team.ID, now)
|
||||
if err == nil {
|
||||
scheduledTeamIDs[team.ID] = calendar.Open
|
||||
}
|
||||
for _, item := range schedules {
|
||||
scheduledTeamIDs[item.TeamID] = true
|
||||
}
|
||||
|
||||
type teamCounter struct {
|
||||
@@ -159,10 +151,10 @@ func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTe
|
||||
}
|
||||
counter.totalAgents++
|
||||
counter.maxConcurrentCapacity += int64(profile.MaxConcurrentCount)
|
||||
if online[profile.UserID] {
|
||||
if profile.LastOnlineAt != nil && now.Sub(*profile.LastOnlineAt) <= onlineWindow {
|
||||
counter.onlineAgents++
|
||||
onlineAgents++
|
||||
if effectiveSupportStatus(profile, now) == enums.ServiceStatusBusy {
|
||||
if profile.ServiceStatus == enums.ServiceStatusBusy {
|
||||
counter.busyAgents++
|
||||
busyAgents++
|
||||
}
|
||||
@@ -262,11 +254,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
|
||||
}
|
||||
|
||||
scheduledTeamIDs := make(map[int64]bool, len(schedules))
|
||||
for _, team := range teams {
|
||||
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), team.ID, now)
|
||||
if err == nil {
|
||||
scheduledTeamIDs[team.ID] = calendar.Open
|
||||
}
|
||||
for _, item := range schedules {
|
||||
scheduledTeamIDs[item.TeamID] = true
|
||||
}
|
||||
var scheduleMissingCount int64
|
||||
for _, team := range teams {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -137,9 +136,9 @@ func (s *knowledgeBaseService) DeleteKnowledgeBase(id int64) error {
|
||||
referencingWorkflows := s.findWorkflowReferencesByKnowledgeBaseID(id)
|
||||
if len(referencingWorkflows) > 0 {
|
||||
if len(referencingWorkflows) == 1 {
|
||||
return errorsx.ForbiddenI18n("error.knowledge.workflowReference", referencingWorkflows[0])
|
||||
return errorsx.Forbidden(fmt.Sprintf("知识库正在被流程「%s」使用,请先从知识检索节点中移除", referencingWorkflows[0]))
|
||||
}
|
||||
return errorsx.ForbiddenI18n("error.knowledge.workflowReferences", len(referencingWorkflows))
|
||||
return errorsx.Forbidden(fmt.Sprintf("知识库正在被 %d 个流程使用,请先从知识检索节点中移除", len(referencingWorkflows)))
|
||||
}
|
||||
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -205,36 +203,3 @@ func assertKnowledgeBaseServiceTestCount(t *testing.T, model any, query string,
|
||||
t.Fatalf("count %T = %d, want %d", model, count, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteKnowledgeBaseWorkflowErrorsLocalized(t *testing.T) {
|
||||
for _, count := range []int{1, 2} {
|
||||
t.Run(string(rune('0'+count)), func(t *testing.T) {
|
||||
setupKnowledgeBaseServiceTestDB(t)
|
||||
kb := createKnowledgeBaseServiceTestBase(t, "Keep this knowledge base")
|
||||
for i := 0; i < count; i++ {
|
||||
createKnowledgeBaseServiceTestWorkflow(t, "流程 %s / Процесс"+string(rune('A'+i)), knowledgeBaseServiceTestWorkflowDefinition([]int64{kb.ID}))
|
||||
}
|
||||
err := KnowledgeBaseService.DeleteKnowledgeBase(kb.ID)
|
||||
localized, ok := err.(*errorsx.I18nError)
|
||||
if !ok {
|
||||
t.Fatalf("expected localized application error, got %T: %v", err, err)
|
||||
}
|
||||
key := "error.knowledge.workflowReference"
|
||||
if count > 1 {
|
||||
key = "error.knowledge.workflowReferences"
|
||||
}
|
||||
if localized.Key != key {
|
||||
t.Fatalf("error key = %q", localized.Key)
|
||||
}
|
||||
for _, locale := range []string{"en-US", "ru-RU"} {
|
||||
message := localized.Message(locale)
|
||||
if strings.Contains(message, "%!") || strings.Contains(message, "error.knowledge") || strings.Contains(message, "知识库正在") {
|
||||
t.Fatalf("bad localized message %q", message)
|
||||
}
|
||||
if count == 1 && !strings.Contains(message, "流程 %s / Процесс") {
|
||||
t.Fatalf("workflow name changed: %q", message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -44,27 +43,27 @@ type knowledgeFAQImportRow struct {
|
||||
Remark string
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) BuildKnowledgeFAQImportTemplate(locales ...string) (*response.KnowledgeFAQExportedFile, error) {
|
||||
func (s *knowledgeFAQService) BuildKnowledgeFAQImportTemplate() (*response.KnowledgeFAQExportedFile, error) {
|
||||
workbook := excelize.NewFile()
|
||||
defer workbook.Close()
|
||||
if err := writeKnowledgeFAQWorkbookHeader(workbook, locales...); err != nil {
|
||||
if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "A2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleQuestion"))
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "B2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleAnswer"))
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "C2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleSimilar"))
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "D2", i18nx.Getf(knowledgeFAQExcelLocale(locales), "knowledge.excel.exampleRemark"))
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "A2", "如何重置密码?")
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "B2", "进入个人设置后点击重置密码。")
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "C2", "忘记密码怎么办\n重置密码在哪里")
|
||||
_ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "D2", "账号")
|
||||
return buildKnowledgeFAQExcelFile("knowledge-faq-import-template.xlsx", workbook)
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) ExportKnowledgeFAQs(knowledgeBaseID int64, locales ...string) (*response.KnowledgeFAQExportedFile, error) {
|
||||
func (s *knowledgeFAQService) ExportKnowledgeFAQs(knowledgeBaseID int64) (*response.KnowledgeFAQExportedFile, error) {
|
||||
if _, err := s.requireFAQKnowledgeBase(knowledgeBaseID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := repositories.KnowledgeFAQRepository.FindAllByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
|
||||
workbook := excelize.NewFile()
|
||||
defer workbook.Close()
|
||||
if err := writeKnowledgeFAQWorkbookHeader(workbook, locales...); err != nil {
|
||||
if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, item := range list {
|
||||
@@ -183,7 +182,7 @@ func (s *knowledgeFAQService) ImportKnowledgeFAQs(req request.ImportKnowledgeFAQ
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File, locales ...string) error {
|
||||
func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File) error {
|
||||
sheet := workbook.GetSheetName(0)
|
||||
if sheet == "" {
|
||||
sheet = knowledgeFAQExcelSheetName
|
||||
@@ -193,13 +192,7 @@ func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File, locales ...string)
|
||||
return err
|
||||
}
|
||||
}
|
||||
locale := knowledgeFAQExcelLocale(locales)
|
||||
headers := []string{
|
||||
i18nx.Getf(locale, "knowledge.excel.question"),
|
||||
i18nx.Getf(locale, "knowledge.excel.answer"),
|
||||
i18nx.Getf(locale, "knowledge.excel.similarQuestions"),
|
||||
i18nx.Getf(locale, "knowledge.excel.remark"),
|
||||
}
|
||||
headers := []string{"标准问题", "答案", "相似问", "备注"}
|
||||
for index, header := range headers {
|
||||
cell, err := excelize.CoordinatesToCellName(index+1, 1)
|
||||
if err != nil {
|
||||
@@ -281,7 +274,7 @@ func parseKnowledgeFAQImportRows(reader io.Reader, locale string) ([]knowledgeFA
|
||||
SimilarQuestions: parseKnowledgeFAQSimilarQuestions(getKnowledgeFAQExcelCell(current, headerMap["similarQuestions"])),
|
||||
Remark: getKnowledgeFAQExcelCell(current, headerMap["remark"]),
|
||||
}
|
||||
if strings.TrimSpace(row.Question) == "" {
|
||||
if row.Question == "" {
|
||||
result.Failed++
|
||||
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0340")})
|
||||
continue
|
||||
@@ -291,7 +284,7 @@ func parseKnowledgeFAQImportRows(reader io.Reader, locale string) ([]knowledgeFA
|
||||
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0341")})
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(row.Answer) == "" {
|
||||
if row.Answer == "" {
|
||||
result.Failed++
|
||||
result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: i18nx.Getf(locale, "error.e0292")})
|
||||
continue
|
||||
@@ -312,34 +305,19 @@ func buildKnowledgeFAQHeaderMap(headerRow []string) map[string]int {
|
||||
"标准问题": "question",
|
||||
"问题": "question",
|
||||
"question": "question",
|
||||
"primary question": "question",
|
||||
"standard question": "question",
|
||||
"основной вопрос": "question",
|
||||
"стандартный вопрос": "question",
|
||||
"вопрос": "question",
|
||||
"答案": "answer",
|
||||
"answer": "answer",
|
||||
"ответ": "answer",
|
||||
"相似问": "similarQuestions",
|
||||
"相似问题": "similarQuestions",
|
||||
"similarquestions": "similarQuestions",
|
||||
"similar questions": "similarQuestions",
|
||||
"similar_questions": "similarQuestions",
|
||||
"похожие вопросы": "similarQuestions",
|
||||
"похожие вопрос": "similarQuestions",
|
||||
"схожие вопросы": "similarQuestions",
|
||||
"备注": "remark",
|
||||
"remark": "remark",
|
||||
"remarks": "remark",
|
||||
"notes": "remark",
|
||||
"примечание": "remark",
|
||||
"примечания": "remark",
|
||||
}
|
||||
result := map[string]int{"similarQuestions": -1, "remark": -1}
|
||||
result := make(map[string]int)
|
||||
for index, header := range headerRow {
|
||||
key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(header, "\uFEFF")))
|
||||
if field, ok := accepted[key]; ok {
|
||||
if existing, exists := result[field]; !exists || existing < 0 {
|
||||
if _, exists := result[field]; !exists {
|
||||
result[field] = index
|
||||
}
|
||||
}
|
||||
@@ -351,8 +329,7 @@ func getKnowledgeFAQExcelCell(row []string, index int) string {
|
||||
if index < 0 || index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
// Keep authored Q/A and notes verbatim; trim only for validation and headers.
|
||||
return row[index]
|
||||
return strings.TrimSpace(row[index])
|
||||
}
|
||||
|
||||
func isKnowledgeFAQExcelRowEmpty(row []string) bool {
|
||||
@@ -369,8 +346,8 @@ func parseKnowledgeFAQSimilarQuestions(value string) []string {
|
||||
items := make([]string, 0, len(lines))
|
||||
seen := make(map[string]struct{}, len(lines))
|
||||
for _, line := range lines {
|
||||
item := strings.TrimSuffix(line, "\r")
|
||||
if strings.TrimSpace(item) == "" {
|
||||
item := strings.TrimSpace(strings.ReplaceAll(line, "\r", ""))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
@@ -392,10 +369,3 @@ func decodeKnowledgeFAQSimilarQuestions(value string) []string {
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func knowledgeFAQExcelLocale(locales []string) string {
|
||||
if len(locales) > 0 {
|
||||
return i18nx.NormalizeLocale(locales[0])
|
||||
}
|
||||
return i18nx.DefaultLocale
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
@@ -20,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func TestKnowledgeFAQExcelTemplateContainsExpectedHeaders(t *testing.T) {
|
||||
file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate("zh-CN")
|
||||
file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate()
|
||||
if err != nil {
|
||||
t.Fatalf("BuildKnowledgeFAQImportTemplate() error = %v", err)
|
||||
}
|
||||
@@ -249,82 +247,3 @@ func stubKnowledgeFAQImportIndex(t *testing.T) func() {
|
||||
indexKnowledgeFAQAfterImport = original
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeFAQExcelLocalizedTemplatesRoundTrip(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
locale string
|
||||
headers []string
|
||||
question string
|
||||
}{
|
||||
{"en-US", []string{"Primary question", "Answer", "Similar questions", "Notes"}, "How do I reset my password?"},
|
||||
{"ru-RU", []string{"Основной вопрос", "Ответ", "Похожие вопросы", "Примечания"}, "Как сбросить пароль?"},
|
||||
{"zh-CN", []string{"标准问题", "答案", "相似问", "备注"}, "如何重置密码?"},
|
||||
} {
|
||||
t.Run(tc.locale, func(t *testing.T) {
|
||||
file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate(tc.locale)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workbook, err := excelize.OpenReader(bytes.NewReader(file.Data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workbook.Close()
|
||||
table, err := workbook.GetRows("FAQ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(table[0], tc.headers) || table[1][0] != tc.question {
|
||||
t.Fatalf("wrong template: %#v", table)
|
||||
}
|
||||
rows, result, err := parseKnowledgeFAQImportRows(bytes.NewReader(file.Data), tc.locale)
|
||||
if err != nil || result.Failed != 0 || len(rows) != 1 || rows[0].Question != tc.question {
|
||||
t.Fatalf("round trip = %+v, %+v, %v", rows, result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeFAQExcelImportAcceptsLegacyAndRussianHeaders(t *testing.T) {
|
||||
for _, headers := range [][]string{
|
||||
{"question", "answer", "similarQuestions", "remark"},
|
||||
{"标准问题", "答案", "相似问", "备注"},
|
||||
{"\ufeffОсновной вопрос", "ОТВЕТ", "Похожие вопросы", "Примечания"},
|
||||
{"Вопрос", "Ответ"},
|
||||
} {
|
||||
question, answer := " Author question 原文 ", "\n Answer 回答:原文\nnext "
|
||||
data := buildKnowledgeFAQExcelWorkbook(t, [][]string{headers, {question, answer}})
|
||||
rows, result, err := parseKnowledgeFAQImportRows(bytes.NewReader(data), "ru-RU")
|
||||
if err != nil || result.Failed != 0 || len(rows) != 1 {
|
||||
t.Fatalf("parse %v = %+v, %v", headers, result, err)
|
||||
}
|
||||
if rows[0].Question != question || rows[0].Answer != answer {
|
||||
t.Fatalf("authored data modified: %+v", rows[0])
|
||||
}
|
||||
if len(rows[0].SimilarQuestions) != 0 || rows[0].Remark != "" {
|
||||
t.Fatalf("missing optional columns copied question: %+v", rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeFAQExcelLocalizedExportPreservesRows(t *testing.T) {
|
||||
setupKnowledgeFAQExcelTestDB(t)
|
||||
kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk)
|
||||
item := &models.KnowledgeFAQ{KnowledgeBaseID: kb.ID, Question: " Original 问题 ", Answer: "\nОтвет <tag>原文</tag> ", SimilarQuestions: `[" same question ","相似问"]`, Remark: " Notes 原文 ", Status: enums.StatusOk}
|
||||
if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, locale := range []string{"en-US", "ru-RU"} {
|
||||
file, err := KnowledgeFAQService.ExportKnowledgeFAQs(kb.ID, locale)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, result, err := parseKnowledgeFAQImportRows(bytes.NewReader(file.Data), locale)
|
||||
if err != nil || result.Failed != 0 || len(rows) != 1 {
|
||||
t.Fatalf("export round trip: %+v, %v", result, err)
|
||||
}
|
||||
if rows[0].Question != item.Question || rows[0].Answer != item.Answer || rows[0].Remark != item.Remark || !reflect.DeepEqual(rows[0].SimilarQuestions, []string{" same question ", "相似问"}) {
|
||||
t.Fatalf("row changed: %+v", rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
package services
|
||||
|
||||
@@ -69,20 +68,12 @@ func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*resp
|
||||
anyScheduleTeamSet := make(map[int64]bool)
|
||||
for _, sch := range schedules {
|
||||
anyScheduleTeamSet[sch.TeamID] = true
|
||||
if sch.Status != enums.StatusOk {
|
||||
continue
|
||||
}
|
||||
for _, team := range teams {
|
||||
calendar, err := SupportCalendarService.get(db, team.ID)
|
||||
if err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
if !sch.StartAt.After(now) && sch.EndAt.After(now) {
|
||||
activeScheduleNowTeamSet[sch.TeamID] = true
|
||||
}
|
||||
if calendar.Mode == "recurring" {
|
||||
anyScheduleTeamSet[team.ID] = len(calendar.Weekly) > 0 || len(calendar.Exceptions) > 0
|
||||
}
|
||||
evaluation, err := SupportCalendarService.evaluateCalendar(db, calendar, now)
|
||||
if err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
activeScheduleNowTeamSet[team.ID] = evaluation.Open
|
||||
}
|
||||
|
||||
var profiles []models.AgentProfile
|
||||
@@ -236,6 +227,7 @@ func (s *setupReadinessService) evaluateSchedulesStep(
|
||||
EntityType: "team",
|
||||
EntityID: team.ID,
|
||||
EntityName: team.Name,
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@@ -291,18 +283,77 @@ func (s *setupReadinessService) evaluateOperatorsStep(
|
||||
displayName = fmt.Sprintf("#%d", p.UserID)
|
||||
}
|
||||
|
||||
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), p, true, time.Now(), 0)
|
||||
if err != nil {
|
||||
eligibility.Reason = "availability_unavailable"
|
||||
}
|
||||
if !eligibility.Eligible {
|
||||
code := supportSetupOperatorIssue(eligibility.Reason)
|
||||
issues = append(issues, response.SetupIssueItem{Code: code, Severity: "warning", EntityType: "operator", EntityID: p.ID, EntityName: displayName})
|
||||
if p.Status != enums.StatusOk {
|
||||
issues = append(issues, response.SetupIssueItem{
|
||||
Code: "operator_profile_disabled",
|
||||
Severity: "warning",
|
||||
EntityType: "operator",
|
||||
EntityID: p.ID,
|
||||
EntityName: displayName,
|
||||
QuickFixAction: QuickFixActionEnableMyProfile,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if _, userOk := activeUserMap[p.UserID]; !userOk {
|
||||
issues = append(issues, response.SetupIssueItem{
|
||||
Code: "operator_user_disabled",
|
||||
Severity: "warning",
|
||||
EntityType: "operator",
|
||||
EntityID: p.ID,
|
||||
EntityName: displayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if _, teamOk := activeTeamMap[p.TeamID]; !teamOk {
|
||||
issues = append(issues, response.SetupIssueItem{
|
||||
Code: "operator_no_active_team",
|
||||
Severity: "error",
|
||||
EntityType: "operator",
|
||||
EntityID: p.ID,
|
||||
EntityName: displayName,
|
||||
QuickFixAction: QuickFixActionEnableMyProfile,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if !p.AutoAssignEnabled {
|
||||
issues = append(issues, response.SetupIssueItem{
|
||||
Code: "operator_auto_assign_off",
|
||||
Severity: "warning",
|
||||
EntityType: "operator",
|
||||
EntityID: p.ID,
|
||||
EntityName: displayName,
|
||||
QuickFixAction: QuickFixActionEnableMyProfile,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if p.ServiceStatus != enums.ServiceStatusIdle {
|
||||
issues = append(issues, response.SetupIssueItem{
|
||||
Code: "operator_busy",
|
||||
Severity: "warning",
|
||||
EntityType: "operator",
|
||||
EntityID: p.ID,
|
||||
EntityName: displayName,
|
||||
QuickFixAction: QuickFixActionEnableMyProfile,
|
||||
})
|
||||
continue
|
||||
}
|
||||
activeCount := activeCounts[p.UserID]
|
||||
if p.MaxConcurrentCount <= 0 || activeCount >= p.MaxConcurrentCount {
|
||||
issues = append(issues, response.SetupIssueItem{
|
||||
Code: "operator_at_capacity",
|
||||
Severity: "warning",
|
||||
EntityType: "operator",
|
||||
EntityID: p.ID,
|
||||
EntityName: displayName,
|
||||
QuickFixAction: QuickFixActionEnableMyProfile,
|
||||
})
|
||||
continue
|
||||
}
|
||||
readyCount++
|
||||
readyOperatorTeams[p.TeamID] = true
|
||||
|
||||
readyCount++
|
||||
if activeScheduleNowTeamSet[p.TeamID] {
|
||||
readyOperatorTeams[p.TeamID] = true
|
||||
}
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
@@ -648,23 +699,32 @@ func (s *setupReadinessService) evaluateCurrentOperator(
|
||||
diag.ActiveConversationCount = int64(activeCounts[operator.UserID])
|
||||
diag.MaxConcurrentCount = matched.MaxConcurrentCount
|
||||
|
||||
if !diag.ProfileStatusOk {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, "profile_disabled")
|
||||
}
|
||||
|
||||
if team, ok := activeTeamMap[matched.TeamID]; ok {
|
||||
diag.TeamName = team.Name
|
||||
diag.TeamActive = true
|
||||
diag.TeamHasScheduleNow = activeScheduleNowTeamSet[team.ID]
|
||||
if !diag.TeamHasScheduleNow {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, "team_off_hours")
|
||||
}
|
||||
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), *matched, true, time.Now(), 0)
|
||||
if err != nil {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, "availability_unavailable")
|
||||
return diag
|
||||
}
|
||||
diag.ServiceStatus = int(eligibility.EffectiveStatus)
|
||||
diag.TeamHasScheduleNow = eligibility.Calendar.Open
|
||||
diag.ActiveConversationCount = int64(eligibility.ActiveCount)
|
||||
diag.CanReceiveNow = eligibility.Eligible
|
||||
if !eligibility.Eligible {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, supportSetupSkipReason(eligibility.Reason))
|
||||
} else {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, "no_team")
|
||||
}
|
||||
|
||||
if !matched.AutoAssignEnabled {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, "auto_assign_disabled")
|
||||
}
|
||||
if matched.ServiceStatus != enums.ServiceStatusIdle {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, "status_busy")
|
||||
}
|
||||
if matched.MaxConcurrentCount <= 0 || int(diag.ActiveConversationCount) >= matched.MaxConcurrentCount {
|
||||
diag.BlockerCodes = append(diag.BlockerCodes, "at_capacity")
|
||||
}
|
||||
|
||||
diag.CanReceiveNow = len(diag.BlockerCodes) == 0
|
||||
return diag
|
||||
}
|
||||
|
||||
@@ -684,9 +744,7 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ
|
||||
var channel models.Channel
|
||||
if req.ChannelID > 0 {
|
||||
if err := db.Where("status <> ?", enums.StatusDeleted).First(&channel, req.ChannelID).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err) }
|
||||
return &response.SetupSimulateRouteResponse{
|
||||
CandidateOperators: []response.SimulatedOperatorCandidate{},
|
||||
WillReachOperator: false,
|
||||
@@ -703,13 +761,9 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ
|
||||
}
|
||||
} else {
|
||||
if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&channel).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err) }
|
||||
if err2 := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").First(&channel).Error; err2 != nil {
|
||||
if !errors.Is(err2, gorm.ErrRecordNotFound) {
|
||||
return nil, setupPersistenceError(err2)
|
||||
}
|
||||
if !errors.Is(err2, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err2) }
|
||||
return &response.SetupSimulateRouteResponse{
|
||||
CandidateOperators: []response.SimulatedOperatorCandidate{},
|
||||
WillReachOperator: false,
|
||||
@@ -788,9 +842,7 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ
|
||||
// Hop 2: AI Agent
|
||||
var aiAgent models.AIAgent
|
||||
agentErr := db.First(&aiAgent, channel.AIAgentID).Error
|
||||
if agentErr != nil && !errors.Is(agentErr, gorm.ErrRecordNotFound) {
|
||||
return nil, setupPersistenceError(agentErr)
|
||||
}
|
||||
if agentErr != nil && !errors.Is(agentErr, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(agentErr) }
|
||||
if agentErr != nil || aiAgent.Status != enums.StatusOk {
|
||||
resp.FinalOutcomeCode = "blocked_missing_ai_agent"
|
||||
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
||||
@@ -830,9 +882,7 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ
|
||||
requestedTeamIDs := utils.SplitInt64s(aiAgent.TeamIDs)
|
||||
var teams []models.AgentTeam
|
||||
if len(requestedTeamIDs) > 0 {
|
||||
if err := db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams).Error; err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
if err := db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams).Error; err != nil { return nil, setupPersistenceError(err) }
|
||||
}
|
||||
if len(teams) == 0 {
|
||||
resp.FinalOutcomeCode = "blocked_agent_no_teams"
|
||||
@@ -864,16 +914,7 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ
|
||||
|
||||
// Hop 4: Shift Schedules (Active Now)
|
||||
activeScheduleTeamIDs := make([]int64, 0)
|
||||
for _, teamID := range enabledTeamIDs {
|
||||
calendar, err := SupportCalendarService.Evaluate(db, teamID, now)
|
||||
if err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
if calendar.Open {
|
||||
activeScheduleTeamIDs = append(activeScheduleTeamIDs, teamID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Model(&models.AgentTeamSchedule{}).Distinct("team_id").Where("team_id IN ? AND status = ? AND start_at <= ? AND end_at > ?", enabledTeamIDs, enums.StatusOk, now, now).Pluck("team_id", &activeScheduleTeamIDs).Error; err != nil { return nil, setupPersistenceError(err) }
|
||||
activeScheduleTeamSet := make(map[int64]bool, len(activeScheduleTeamIDs))
|
||||
activeScheduleNames := make([]string, 0, len(activeScheduleTeamIDs))
|
||||
for _, tid := range activeScheduleTeamIDs {
|
||||
@@ -890,11 +931,10 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ
|
||||
Status: "error",
|
||||
ReasonCode: "queued_off_hours_no_schedule",
|
||||
EntityName: strings.Join(teamNames, ", "),
|
||||
|
||||
})
|
||||
candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil { return nil, err }
|
||||
resp.CandidateOperators = candidates
|
||||
return resp, nil
|
||||
}
|
||||
@@ -907,9 +947,7 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ
|
||||
|
||||
// Hop 5: Operator Candidate Selection
|
||||
candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil { return nil, err }
|
||||
resp.CandidateOperators = candidates
|
||||
|
||||
eligibleCandidates, _, err := ConversationDispatchService.pickDispatchCandidates(enabledTeamIDs, now)
|
||||
@@ -965,26 +1003,15 @@ func (s *setupReadinessService) buildCandidateListForTeams(
|
||||
db := sqls.DB()
|
||||
teamIDs := make([]int64, 0, len(enabledTeamIDSet))
|
||||
teamNameMap := make(map[int64]string, len(enabledTeamIDSet))
|
||||
for id, team := range enabledTeamIDSet {
|
||||
teamIDs = append(teamIDs, id)
|
||||
teamNameMap[id] = team.Name
|
||||
}
|
||||
for id, team := range enabledTeamIDSet { teamIDs = append(teamIDs, id); teamNameMap[id] = team.Name }
|
||||
var allProfiles []models.AgentProfile
|
||||
if err := db.Where("team_id IN ? AND status <> ?", teamIDs, enums.StatusDeleted).Order("id ASC").Find(&allProfiles).Error; err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
if err := db.Where("team_id IN ? AND status <> ?", teamIDs, enums.StatusDeleted).Order("id ASC").Find(&allProfiles).Error; err != nil { return nil, setupPersistenceError(err) }
|
||||
var users []models.User
|
||||
if err := db.Where("id IN ?", extractProfileUserIDs(allProfiles)).Find(&users).Error; err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
if err := db.Where("id IN ?", extractProfileUserIDs(allProfiles)).Find(&users).Error; err != nil { return nil, setupPersistenceError(err) }
|
||||
userMap := make(map[int64]models.User, len(users))
|
||||
for _, u := range users {
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
for _, u := range users { userMap[u.ID] = u }
|
||||
activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(allProfiles))
|
||||
if err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
if err != nil { return nil, setupPersistenceError(err) }
|
||||
result := make([]response.SimulatedOperatorCandidate, 0, len(allProfiles))
|
||||
|
||||
for _, p := range allProfiles {
|
||||
@@ -997,13 +1024,22 @@ func (s *setupReadinessService) buildCandidateListForTeams(
|
||||
displayName = fmt.Sprintf("#%d", p.UserID)
|
||||
}
|
||||
|
||||
eligibility, err := SupportAvailabilityService.Evaluate(db, p, true, time.Now(), 0)
|
||||
if err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
skipReason := ""
|
||||
if !eligibility.Eligible {
|
||||
skipReason = supportSetupSkipReason(eligibility.Reason)
|
||||
switch {
|
||||
case p.Status != enums.StatusOk:
|
||||
skipReason = "profile_disabled"
|
||||
case !hasUser || u.Status != enums.StatusOk || u.DeletedAt != nil:
|
||||
skipReason = "user_disabled"
|
||||
case enabledTeamIDSet[p.TeamID].ID == 0:
|
||||
skipReason = "team_not_in_agent"
|
||||
case !activeScheduleTeamSet[p.TeamID]:
|
||||
skipReason = "team_off_hours"
|
||||
case !p.AutoAssignEnabled:
|
||||
skipReason = "auto_assign_off"
|
||||
case p.ServiceStatus != enums.ServiceStatusIdle:
|
||||
skipReason = "status_busy"
|
||||
case p.MaxConcurrentCount <= 0 || activeCounts[p.UserID] >= p.MaxConcurrentCount:
|
||||
skipReason = "at_capacity"
|
||||
}
|
||||
|
||||
result = append(result, response.SimulatedOperatorCandidate{
|
||||
@@ -1015,7 +1051,7 @@ func (s *setupReadinessService) buildCandidateListForTeams(
|
||||
SkipReasonCode: skipReason,
|
||||
ActiveCount: int64(activeCounts[p.UserID]),
|
||||
MaxConcurrentCount: p.MaxConcurrentCount,
|
||||
ServiceStatus: int(eligibility.EffectiveStatus),
|
||||
ServiceStatus: int(p.ServiceStatus),
|
||||
AutoAssignEnabled: p.AutoAssignEnabled,
|
||||
})
|
||||
}
|
||||
@@ -1113,9 +1149,7 @@ func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest,
|
||||
default:
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.invalidQuickFixAction", req.Action)
|
||||
}
|
||||
if req.EntityID < 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
||||
}
|
||||
if req.EntityID < 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") }
|
||||
if !s.canRunQuickFixAction(operator, req.Action) {
|
||||
return nil, errorsx.ForbiddenI18n("error.e0225")
|
||||
}
|
||||
@@ -1158,9 +1192,7 @@ func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest,
|
||||
})
|
||||
if err != nil {
|
||||
var appError *errorsx.I18nError
|
||||
if errors.As(err, &appError) {
|
||||
return nil, err
|
||||
}
|
||||
if errors.As(err, &appError) { return nil, err }
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
res.Applied = req.Apply
|
||||
@@ -1183,9 +1215,7 @@ func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest,
|
||||
}
|
||||
|
||||
func setupPersistenceError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err == nil { return nil }
|
||||
return errorsx.BusinessErrorI18n(0, "error.setup.persistenceFailed")
|
||||
}
|
||||
|
||||
@@ -1200,13 +1230,9 @@ func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuick
|
||||
// Each read gets a fresh statement; the shared handle may carry a row lock,
|
||||
// but must not retain the previous Find model/table or profile filter.
|
||||
for _, dest := range []any{&teams, &agents, &channels} {
|
||||
if err := db.Session(&gorm.Session{}).Order("id ASC").Find(dest).Error; err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
}
|
||||
}
|
||||
if err := db.Session(&gorm.Session{}).Where("user_id = ?", operator.UserID).Order("id ASC").Find(&profiles).Error; err != nil {
|
||||
return nil, setupPersistenceError(err)
|
||||
if err := db.Session(&gorm.Session{}).Order("id ASC").Find(dest).Error; err != nil { return nil, setupPersistenceError(err) }
|
||||
}
|
||||
if err := db.Session(&gorm.Session{}).Where("user_id = ?", operator.UserID).Order("id ASC").Find(&profiles).Error; err != nil { return nil, setupPersistenceError(err) }
|
||||
if req.Action == QuickFixActionEnableMyProfile && req.EntityID > 0 && (len(profiles) == 0 || profiles[0].ID != req.EntityID) {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
||||
}
|
||||
@@ -1214,9 +1240,7 @@ func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuick
|
||||
now := time.Now()
|
||||
audit := models.AuditFields{CreatedAt: now, UpdatedAt: now, CreateUserID: operator.UserID, UpdateUserID: operator.UserID, CreateUserName: operator.Username, UpdateUserName: operator.Username}
|
||||
require := func(codes ...string) error {
|
||||
if !s.hasAnyPermission(operator, codes...) {
|
||||
return errorsx.ForbiddenI18n("error.e0225")
|
||||
}
|
||||
if !s.hasAnyPermission(operator, codes...) { return errorsx.ForbiddenI18n("error.e0225") }
|
||||
return nil
|
||||
}
|
||||
add := func(kind string, id int64, name, operation string, before, after map[string]any, run func(*gorm.DB) error) {
|
||||
@@ -1225,88 +1249,55 @@ func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuick
|
||||
}
|
||||
update := func(model any, kind string, id int64, name string, before, after map[string]any, updatedAt time.Time) {
|
||||
for field, value := range after {
|
||||
if reflect.DeepEqual(before[field], value) {
|
||||
delete(before, field)
|
||||
delete(after, field)
|
||||
}
|
||||
}
|
||||
if len(after) == 0 {
|
||||
return
|
||||
if reflect.DeepEqual(before[field], value) { delete(before, field); delete(after, field) }
|
||||
}
|
||||
if len(after) == 0 { return }
|
||||
add(kind, id, name, "update", before, after, func(tx *gorm.DB) error {
|
||||
updates := make(map[string]any, len(after)+3)
|
||||
for key, value := range after {
|
||||
updates[key] = value
|
||||
}
|
||||
for key, value := range after { updates[key] = value }
|
||||
updates["updated_at"] = now
|
||||
updates["update_user_id"] = operator.UserID
|
||||
updates["update_user_name"] = operator.Username
|
||||
result := tx.Model(model).Where("id = ? AND status <> ? AND updated_at = ?", id, enums.StatusDeleted, updatedAt).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return setupPersistenceError(result.Error)
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errorsx.InvalidParamI18n("error.setup.previewChanged")
|
||||
}
|
||||
if result.Error != nil { return setupPersistenceError(result.Error) }
|
||||
if result.RowsAffected != 1 { return errorsx.InvalidParamI18n("error.setup.previewChanged") }
|
||||
return nil
|
||||
})
|
||||
}
|
||||
all := req.Action == QuickFixActionQuickSetupAll
|
||||
if all && req.EntityID != 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
||||
}
|
||||
if all && req.EntityID != 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") }
|
||||
|
||||
var team *models.AgentTeam
|
||||
nonDeletedTeams := 0
|
||||
for i := range teams {
|
||||
if teams[i].Status != enums.StatusDeleted {
|
||||
nonDeletedTeams++
|
||||
}
|
||||
if team == nil && teams[i].Status == enums.StatusOk {
|
||||
team = &teams[i]
|
||||
}
|
||||
if teams[i].Status != enums.StatusDeleted { nonDeletedTeams++ }
|
||||
if team == nil && teams[i].Status == enums.StatusOk { team = &teams[i] }
|
||||
}
|
||||
if req.Action == QuickFixActionEnsureTeamSchedule && req.EntityID > 0 {
|
||||
team = nil
|
||||
for i := range teams {
|
||||
if teams[i].ID == req.EntityID && teams[i].Status == enums.StatusOk {
|
||||
team = &teams[i]
|
||||
}
|
||||
}
|
||||
if team == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
||||
if teams[i].ID == req.EntityID && teams[i].Status == enums.StatusOk { team = &teams[i] }
|
||||
}
|
||||
if team == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") }
|
||||
}
|
||||
if (all || req.Action == QuickFixActionEnsureTeamSchedule) && team == nil && nonDeletedTeams == 0 && req.EntityID == 0 {
|
||||
if err := require(constants.PermissionAgentTeamCreate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := require(constants.PermissionAgentTeamCreate.Code); err != nil { return nil, err }
|
||||
team = &models.AgentTeam{Name: "Default Support Team", LeaderUserID: operator.UserID, Status: enums.StatusOk, AuditFields: audit}
|
||||
add("team", 0, team.Name, "create", nil, map[string]any{"name": team.Name, "leader_user_id": operator.UserID, "status": enums.StatusOk}, func(tx *gorm.DB) error { return setupPersistenceError(tx.Create(team).Error) })
|
||||
}
|
||||
// A team being off duty is not a broken schedule. The existing schedule
|
||||
// editor is the only place to add explicit hours; quick fixes never add shifts.
|
||||
if all || req.Action == QuickFixActionEnsureTeamSchedule {
|
||||
plan.warnings = append(plan.warnings, "schedules_unchanged")
|
||||
}
|
||||
if team == nil {
|
||||
plan.warnings = append(plan.warnings, "active_team_required")
|
||||
}
|
||||
if all || req.Action == QuickFixActionEnsureTeamSchedule { plan.warnings = append(plan.warnings, "schedules_unchanged") }
|
||||
if team == nil { plan.warnings = append(plan.warnings, "active_team_required") }
|
||||
teamRef := func() any {
|
||||
if team == nil {
|
||||
return nil
|
||||
}
|
||||
if team.ID > 0 {
|
||||
return team.ID
|
||||
}
|
||||
if team == nil { return nil }
|
||||
if team.ID > 0 { return team.ID }
|
||||
return team.Name
|
||||
}
|
||||
|
||||
if all || req.Action == QuickFixActionEnableMyProfile {
|
||||
if len(profiles) == 0 && team != nil {
|
||||
if err := require(constants.PermissionAgentCreate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := require(constants.PermissionAgentCreate.Code); err != nil { return nil, err }
|
||||
profile := &models.AgentProfile{UserID: operator.UserID, AgentCode: fmt.Sprintf("AG%04d", operator.UserID), DisplayName: firstNonEmpty(operator.Nickname, operator.Username, fmt.Sprintf("Agent-%d", operator.UserID)), ServiceStatus: enums.ServiceStatusIdle, MaxConcurrentCount: 10, PriorityLevel: 1, AutoAssignEnabled: true, ReceiveOfflineMessage: true, Status: enums.StatusOk, AuditFields: audit}
|
||||
add("operator", 0, profile.DisplayName, "create", nil, map[string]any{"user_id": operator.UserID, "team_id": teamRef(), "status": enums.StatusOk, "service_status": enums.ServiceStatusIdle, "auto_assign_enabled": true, "max_concurrent_count": 10, "priority_level": 1, "receive_offline_message": true}, func(tx *gorm.DB) error {
|
||||
profile.TeamID = team.ID
|
||||
@@ -1314,32 +1305,20 @@ func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuick
|
||||
})
|
||||
} else if !all && len(profiles) > 0 {
|
||||
profile := profiles[0]
|
||||
if profile.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
||||
}
|
||||
if profile.Status == enums.StatusDeleted { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") }
|
||||
before, after := map[string]any{}, map[string]any{}
|
||||
if profile.Status != enums.StatusOk {
|
||||
if err := require(constants.PermissionAgentUpdate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := require(constants.PermissionAgentUpdate.Code); err != nil { return nil, err }
|
||||
before["status"], after["status"] = profile.Status, enums.StatusOk
|
||||
}
|
||||
if profile.ServiceStatus == enums.ServiceStatusBusy {
|
||||
if err := require(constants.PermissionAgentUpdateStatus.Code, constants.PermissionAgentUpdate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile.ServiceStatus != enums.ServiceStatusIdle {
|
||||
if err := require(constants.PermissionAgentUpdateStatus.Code, constants.PermissionAgentUpdate.Code); err != nil { return nil, err }
|
||||
before["service_status"], after["service_status"] = profile.ServiceStatus, enums.ServiceStatusIdle
|
||||
}
|
||||
if !profile.AutoAssignEnabled || profile.MaxConcurrentCount < 0 {
|
||||
if err := require(constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !profile.AutoAssignEnabled {
|
||||
before["auto_assign_enabled"], after["auto_assign_enabled"] = false, true
|
||||
}
|
||||
if profile.MaxConcurrentCount < 0 {
|
||||
before["max_concurrent_count"], after["max_concurrent_count"] = profile.MaxConcurrentCount, 10
|
||||
}
|
||||
if !profile.AutoAssignEnabled || profile.MaxConcurrentCount <= 0 {
|
||||
if err := require(constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdate.Code); err != nil { return nil, err }
|
||||
if !profile.AutoAssignEnabled { before["auto_assign_enabled"], after["auto_assign_enabled"] = false, true }
|
||||
if profile.MaxConcurrentCount <= 0 { before["max_concurrent_count"], after["max_concurrent_count"] = profile.MaxConcurrentCount, 10 }
|
||||
}
|
||||
// Do not silently move an existing operator to a different team.
|
||||
update(&models.AgentProfile{}, "operator", profile.ID, profile.DisplayName, before, after, profile.UpdatedAt)
|
||||
@@ -1349,54 +1328,32 @@ func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuick
|
||||
var agent *models.AIAgent
|
||||
nonDeletedAgents := 0
|
||||
for i := range agents {
|
||||
if agents[i].Status != enums.StatusDeleted {
|
||||
nonDeletedAgents++
|
||||
}
|
||||
if agent == nil && agents[i].Status == enums.StatusOk && agents[i].RolloutPercent > 0 && agents[i].PublishedRevisionID > 0 {
|
||||
agent = &agents[i]
|
||||
}
|
||||
if agents[i].Status != enums.StatusDeleted { nonDeletedAgents++ }
|
||||
if agent == nil && agents[i].Status == enums.StatusOk && agents[i].RolloutPercent > 0 && agents[i].PublishedRevisionID > 0 { agent = &agents[i] }
|
||||
}
|
||||
if all || req.Action == QuickFixActionFixAIAgentHandoff {
|
||||
if nonDeletedAgents == 0 && req.EntityID == 0 && team != nil {
|
||||
if err := require(constants.PermissionAIAgentCreate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := require(constants.PermissionAIAgentCreate.Code); err != nil { return nil, err }
|
||||
agent = &models.AIAgent{Name: "Default Reception Agent", Status: enums.StatusOk, ServiceMode: enums.IMConversationServiceModeHumanOnly, RolloutPercent: 100, HandoffMode: enums.AIAgentHandoffModeDefaultTeamPool, FallbackMode: enums.AIAgentFallbackModeHandoff, AutoHandoffOnFailure: true, ReplyTimeoutSeconds: 180, MaxSteps: 6, AuditFields: audit}
|
||||
add("ai_agent", 0, agent.Name, "create", nil, map[string]any{"name": agent.Name, "status": enums.StatusOk, "team_ids": teamRef(), "service_mode": agent.ServiceMode, "rollout_percent": 100, "handoff_mode": agent.HandoffMode, "fallback_mode": agent.FallbackMode, "auto_handoff_on_failure": true, "reply_timeout_seconds": 180, "max_steps": 6}, func(tx *gorm.DB) error {
|
||||
agent.TeamIDs = strconv.FormatInt(team.ID, 10)
|
||||
return setupPersistenceError(tx.Create(agent).Error)
|
||||
})
|
||||
} else if !all {
|
||||
if req.EntityID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
||||
}
|
||||
if req.EntityID <= 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") }
|
||||
var target *models.AIAgent
|
||||
for i := range agents {
|
||||
if agents[i].ID == req.EntityID && agents[i].Status != enums.StatusDeleted {
|
||||
target = &agents[i]
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
||||
}
|
||||
for i := range agents { if agents[i].ID == req.EntityID && agents[i].Status != enums.StatusDeleted { target = &agents[i] } }
|
||||
if target == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") }
|
||||
if target.Status != enums.StatusOk || target.RolloutPercent <= 0 {
|
||||
plan.warnings = append(plan.warnings, "disabled_configuration_preserved")
|
||||
} else {
|
||||
if err := require(constants.PermissionAIAgentUpdate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := require(constants.PermissionAIAgentUpdate.Code); err != nil { return nil, err }
|
||||
before, after := map[string]any{}, map[string]any{}
|
||||
if !target.AutoHandoffOnFailure {
|
||||
before["auto_handoff_on_failure"], after["auto_handoff_on_failure"] = false, true
|
||||
}
|
||||
if target.ServiceMode == enums.IMConversationServiceModeAIOnly {
|
||||
before["service_mode"], after["service_mode"] = target.ServiceMode, enums.IMConversationServiceModeAIFirst
|
||||
}
|
||||
if !target.AutoHandoffOnFailure { before["auto_handoff_on_failure"], after["auto_handoff_on_failure"] = false, true }
|
||||
if target.ServiceMode == enums.IMConversationServiceModeAIOnly { before["service_mode"], after["service_mode"] = target.ServiceMode, enums.IMConversationServiceModeAIFirst }
|
||||
// Team membership and AI configuration need deliberate selection in
|
||||
// the existing editor, not expansion to every active team.
|
||||
if len(utils.SplitInt64s(target.TeamIDs)) == 0 {
|
||||
plan.warnings = append(plan.warnings, "agent_team_selection_required")
|
||||
}
|
||||
if len(utils.SplitInt64s(target.TeamIDs)) == 0 { plan.warnings = append(plan.warnings, "agent_team_selection_required") }
|
||||
update(&models.AIAgent{}, "ai_agent", target.ID, target.Name, before, after, target.UpdatedAt)
|
||||
}
|
||||
}
|
||||
@@ -1404,20 +1361,12 @@ func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuick
|
||||
|
||||
if all || req.Action == QuickFixActionEnsureDefaultChannel {
|
||||
nonDeletedChannels := 0
|
||||
for _, channel := range channels {
|
||||
if channel.Status != enums.StatusDeleted {
|
||||
nonDeletedChannels++
|
||||
}
|
||||
}
|
||||
for _, channel := range channels { if channel.Status != enums.StatusDeleted { nonDeletedChannels++ } }
|
||||
if nonDeletedChannels == 0 && req.EntityID == 0 && agent != nil && agent.PublishedRevisionID > 0 {
|
||||
if err := require(constants.PermissionChannelCreate.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := require(constants.PermissionChannelCreate.Code); err != nil { return nil, err }
|
||||
channel := &models.Channel{Name: "Default Web Support Channel", ChannelType: string(enums.ChannelTypeWeb), ChannelID: fmt.Sprintf("web_setup_%d_%d", operator.UserID, len(channels)+1), AIAgentRolloutPercent: 100, ConfigJSON: `{"position":"right"}`, Status: enums.StatusOk, AuditFields: audit}
|
||||
agentRef := any(agent.ID)
|
||||
if agent.ID == 0 {
|
||||
agentRef = agent.Name
|
||||
}
|
||||
if agent.ID == 0 { agentRef = agent.Name }
|
||||
add("channel", 0, channel.Name, "create", nil, map[string]any{"name": channel.Name, "channel_id": channel.ChannelID, "channel_type": channel.ChannelType, "ai_agent_id": agentRef, "status": enums.StatusOk, "ai_agent_rollout_percent": 100, "config_json": channel.ConfigJSON}, func(tx *gorm.DB) error {
|
||||
channel.AIAgentID = agent.ID
|
||||
return setupPersistenceError(tx.Create(channel).Error)
|
||||
@@ -1427,26 +1376,16 @@ func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuick
|
||||
// agent is a separate reviewed workflow, never implicit in setup.
|
||||
plan.warnings = append(plan.warnings, "agent_publication_required")
|
||||
} else if !all {
|
||||
if req.EntityID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
||||
}
|
||||
if req.EntityID <= 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") }
|
||||
var target *models.Channel
|
||||
for i := range channels {
|
||||
if channels[i].ID == req.EntityID && channels[i].Status != enums.StatusDeleted {
|
||||
target = &channels[i]
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
||||
}
|
||||
for i := range channels { if channels[i].ID == req.EntityID && channels[i].Status != enums.StatusDeleted { target = &channels[i] } }
|
||||
if target == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") }
|
||||
// Never replace an explicitly configured channel route or rollout with
|
||||
// the first agent in the database. Use the existing channel editor.
|
||||
plan.warnings = append(plan.warnings, "channel_selection_required")
|
||||
}
|
||||
}
|
||||
if all {
|
||||
plan.warnings = append(plan.warnings, "existing_configuration_preserved")
|
||||
}
|
||||
if all { plan.warnings = append(plan.warnings, "existing_configuration_preserved") }
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -1471,40 +1410,3 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func supportSetupSkipReason(reason string) string {
|
||||
switch reason {
|
||||
case "eligible":
|
||||
return ""
|
||||
case "outside_hours":
|
||||
return "team_off_hours"
|
||||
case "team_disabled":
|
||||
return "no_team"
|
||||
case "auto_assign_disabled":
|
||||
return "auto_assign_disabled"
|
||||
case "busy":
|
||||
return "status_busy"
|
||||
case "capacity":
|
||||
return "at_capacity"
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
func supportSetupOperatorIssue(reason string) string {
|
||||
switch reason {
|
||||
case "profile_disabled":
|
||||
return "operator_profile_disabled"
|
||||
case "user_disabled":
|
||||
return "operator_user_disabled"
|
||||
case "team_disabled":
|
||||
return "operator_no_active_team"
|
||||
case "auto_assign_disabled":
|
||||
return "operator_auto_assign_off"
|
||||
case "busy":
|
||||
return "operator_busy"
|
||||
case "capacity":
|
||||
return "operator_at_capacity"
|
||||
default:
|
||||
return "operator_" + reason
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
package services_test
|
||||
|
||||
@@ -50,7 +49,6 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
|
||||
&models.AgentTeam{},
|
||||
&models.AgentTeamSchedule{},
|
||||
&models.AgentProfile{},
|
||||
&models.SupportCalendar{}, &models.AgentPresenceLease{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{},
|
||||
&models.Channel{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationParticipant{},
|
||||
@@ -69,9 +67,7 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
|
||||
func setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
|
||||
t.Helper()
|
||||
user := models.User{Username: "setup-admin", Nickname: "Setup Admin", Status: enums.StatusOk}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil { t.Fatal(err) }
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: user.ID, Username: user.Username, Nickname: user.Nickname,
|
||||
Roles: []string{constants.RoleCodeAdmin},
|
||||
@@ -89,32 +85,20 @@ func setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
|
||||
func applyReviewedSetupFix(t *testing.T, req request.SetupQuickFixRequest, principal *dto.AuthPrincipal) *response.SetupQuickFixResponse {
|
||||
t.Helper()
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
if preview.Applied || preview.PreviewToken == "" {
|
||||
t.Fatalf("expected read-only preview: %+v", preview)
|
||||
}
|
||||
if err != nil { t.Fatalf("preview: %v", err) }
|
||||
if preview.Applied || preview.PreviewToken == "" { t.Fatalf("expected read-only preview: %+v", preview) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if !applied.Applied {
|
||||
t.Fatal("reviewed changes were not applied")
|
||||
}
|
||||
if err != nil { t.Fatalf("apply: %v", err) }
|
||||
if !applied.Applied { t.Fatal("reviewed changes were not applied") }
|
||||
return applied
|
||||
}
|
||||
|
||||
func assertSetupCount(t *testing.T, db *gorm.DB, model any, want int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("%T count = %d, want %d", model, count, want)
|
||||
}
|
||||
if err := db.Model(model).Count(&count).Error; err != nil { t.Fatal(err) }
|
||||
if count != want { t.Fatalf("%T count = %d, want %d", model, count, want) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_RequiresReviewedExplicitApply(t *testing.T) {
|
||||
@@ -122,41 +106,23 @@ func TestSetupReadiness_RequiresReviewedExplicitApply(t *testing.T) {
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.Applied || len(preview.Changes) != 3 {
|
||||
t.Fatalf("expected three proposed draft setup records: %+v", preview)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if preview.Applied || len(preview.Changes) != 3 { t.Fatalf("expected three proposed draft setup records: %+v", preview) }
|
||||
for _, change := range preview.Changes {
|
||||
if change.EntityName == "" || len(change.After) == 0 {
|
||||
t.Fatalf("unnamed or unspecified change: %+v", change)
|
||||
}
|
||||
}
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} {
|
||||
assertSetupCount(t, db, model, 0)
|
||||
if change.EntityName == "" || len(change.After) == 0 { t.Fatalf("unnamed or unspecified change: %+v", change) }
|
||||
}
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} { assertSetupCount(t, db, model, 0) }
|
||||
req.Apply = true
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
|
||||
t.Fatal("apply without preview must fail")
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("apply without preview must fail") }
|
||||
req.PreviewToken = preview.PreviewToken
|
||||
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !applied.Applied || applied.Readiness == nil || applied.Readiness.CanReceiveRequestsNow {
|
||||
t.Fatalf("configuration must not claim shift coverage: %+v", applied)
|
||||
}
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} {
|
||||
assertSetupCount(t, db, model, 1)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if !applied.Applied || applied.Readiness == nil || applied.Readiness.CanReceiveRequestsNow { t.Fatalf("configuration must not claim shift coverage: %+v", applied) }
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 0)
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
second := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
if len(second.Changes) != 0 {
|
||||
t.Fatalf("existing configuration must be preserved: %+v", second.Changes)
|
||||
}
|
||||
if len(second.Changes) != 0 { t.Fatalf("existing configuration must be preserved: %+v", second.Changes) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_PreviewInvalidatedByConfigurationChange(t *testing.T) {
|
||||
@@ -164,16 +130,10 @@ func TestSetupReadiness_PreviewInvalidatedByConfigurationChange(t *testing.T) {
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.AgentTeam{Name: "Deliberately chosen team", Status: enums.StatusOk}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := db.Create(&models.AgentTeam{Name: "Deliberately chosen team", Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
|
||||
t.Fatal("stale preview must fail")
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("stale preview must fail") }
|
||||
assertSetupCount(t, db, &models.AgentTeam{}, 1)
|
||||
assertSetupCount(t, db, &models.AgentProfile{}, 0)
|
||||
assertSetupCount(t, db, &models.AIAgent{}, 0)
|
||||
@@ -185,23 +145,13 @@ func TestSetupReadiness_RepairTransactionRollsBackAllWrites(t *testing.T) {
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("fail_setup_agent", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "AIAgent" {
|
||||
tx.AddError(errors.New("injected agent write failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "AIAgent" { tx.AddError(errors.New("injected agent write failure")) }
|
||||
}); err != nil { t.Fatal(err) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
|
||||
t.Fatal("expected injected transaction failure")
|
||||
}
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}} {
|
||||
assertSetupCount(t, db, model, 0)
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("expected injected transaction failure") }
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}} { assertSetupCount(t, db, model, 0) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_PreservesDeletedDisabledAndZeroRollout(t *testing.T) {
|
||||
@@ -215,49 +165,25 @@ func TestSetupReadiness_PreservesDeletedDisabledAndZeroRollout(t *testing.T) {
|
||||
zeroAgent := models.AIAgent{Name: "Zero rollout", Status: enums.StatusOk, RolloutPercent: 100}
|
||||
deletedChannel := models.Channel{Name: "Deleted channel", ChannelID: "deleted-channel", Status: enums.StatusDeleted}
|
||||
disabledChannel := models.Channel{Name: "Disabled channel", ChannelID: "disabled-channel", Status: enums.StatusDisabled, AIAgentRolloutPercent: 100}
|
||||
for _, model := range []any{&team, &disabledTeam, &profile, &deletedAgent, &disabledAgent, &zeroAgent, &deletedChannel, &disabledChannel} {
|
||||
if err := db.Create(model).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(&zeroAgent).Update("rollout_percent", 0).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&disabledChannel).Update("ai_agent_rollout_percent", 0).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, model := range []any{&team, &disabledTeam, &profile, &deletedAgent, &disabledAgent, &zeroAgent, &deletedChannel, &disabledChannel} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } }
|
||||
if err := db.Model(&zeroAgent).Update("rollout_percent", 0).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.Model(&disabledChannel).Update("ai_agent_rollout_percent", 0).Error; err != nil { t.Fatal(err) }
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
if len(result.Changes) != 0 {
|
||||
t.Fatalf("broad repair must preserve intentional configurations: %+v", result.Changes)
|
||||
}
|
||||
if len(result.Changes) != 0 { t.Fatalf("broad repair must preserve intentional configurations: %+v", result.Changes) }
|
||||
for _, req := range []request.SetupQuickFixRequest{
|
||||
{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID},
|
||||
{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: deletedAgent.ID},
|
||||
{Action: services.QuickFixActionEnsureDefaultChannel, EntityID: deletedChannel.ID},
|
||||
{Action: services.QuickFixActionEnableMyProfile},
|
||||
} {
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil {
|
||||
t.Fatalf("deleted target must not be restored: %+v", req)
|
||||
}
|
||||
}
|
||||
} { if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatalf("deleted target must not be restored: %+v", req) } }
|
||||
for _, id := range []int64{disabledAgent.ID, zeroAgent.ID} {
|
||||
res := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: id}, principal)
|
||||
if len(res.Changes) != 0 {
|
||||
t.Fatalf("disabled/zero rollout changed: %+v", res)
|
||||
}
|
||||
}
|
||||
if err := db.First(&zeroAgent, zeroAgent.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zeroAgent.RolloutPercent != 0 {
|
||||
t.Fatal("zero rollout was overwritten")
|
||||
}
|
||||
if err := db.First(&disabledChannel, disabledChannel.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if disabledChannel.Status != enums.StatusDisabled || disabledChannel.AIAgentRolloutPercent != 0 {
|
||||
t.Fatal("disabled channel was overwritten")
|
||||
if len(res.Changes) != 0 { t.Fatalf("disabled/zero rollout changed: %+v", res) }
|
||||
}
|
||||
if err := db.First(&zeroAgent, zeroAgent.ID).Error; err != nil { t.Fatal(err) }
|
||||
if zeroAgent.RolloutPercent != 0 { t.Fatal("zero rollout was overwritten") }
|
||||
if err := db.First(&disabledChannel, disabledChannel.ID).Error; err != nil { t.Fatal(err) }
|
||||
if disabledChannel.Status != enums.StatusDisabled || disabledChannel.AIAgentRolloutPercent != 0 { t.Fatal("disabled channel was overwritten") }
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
}
|
||||
|
||||
@@ -267,32 +193,16 @@ func TestSetupReadiness_TargetedHandoffDoesNotRewriteOtherAgents(t *testing.T) {
|
||||
first := models.AIAgent{Name: "Selected", Status: enums.StatusOk, RolloutPercent: 25, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "12"}
|
||||
second := models.AIAgent{Name: "Unrelated", Status: enums.StatusOk, RolloutPercent: 10, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "23"}
|
||||
for _, agent := range []*models.AIAgent{&first, &second} {
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(agent).Update("auto_handoff_on_failure", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff}, principal); err == nil {
|
||||
t.Fatal("existing agents require a named target")
|
||||
if err := db.Create(agent).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.Model(agent).Update("auto_handoff_on_failure", false).Error; err != nil { t.Fatal(err) }
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff}, principal); err == nil { t.Fatal("existing agents require a named target") }
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: first.ID}, principal)
|
||||
if len(result.Changes) != 1 || result.Changes[0].EntityID != first.ID {
|
||||
t.Fatalf("wrong scope: %+v", result.Changes)
|
||||
}
|
||||
if err := db.First(&first, first.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&second, second.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.ServiceMode != enums.IMConversationServiceModeAIFirst || first.RolloutPercent != 25 || first.TeamIDs != "12" {
|
||||
t.Fatal("selected agent was not minimally updated")
|
||||
}
|
||||
if second.ServiceMode != enums.IMConversationServiceModeAIOnly || second.AutoHandoffOnFailure || second.RolloutPercent != 10 || second.TeamIDs != "23" {
|
||||
t.Fatal("unrelated agent was changed")
|
||||
}
|
||||
if len(result.Changes) != 1 || result.Changes[0].EntityID != first.ID { t.Fatalf("wrong scope: %+v", result.Changes) }
|
||||
if err := db.First(&first, first.ID).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.First(&second, second.ID).Error; err != nil { t.Fatal(err) }
|
||||
if first.ServiceMode != enums.IMConversationServiceModeAIFirst || first.RolloutPercent != 25 || first.TeamIDs != "12" { t.Fatal("selected agent was not minimally updated") }
|
||||
if second.ServiceMode != enums.IMConversationServiceModeAIOnly || second.AutoHandoffOnFailure || second.RolloutPercent != 10 || second.TeamIDs != "23" { t.Fatal("unrelated agent was changed") }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_OffHoursIsNotMissingSchedule(t *testing.T) {
|
||||
@@ -301,43 +211,23 @@ func TestSetupReadiness_OffHoursIsNotMissingSchedule(t *testing.T) {
|
||||
now := time.Now()
|
||||
team := models.AgentTeam{Name: "Day shift", Status: enums.StatusOk}
|
||||
other := models.AgentTeam{Name: "Other team", Status: enums.StatusOk}
|
||||
for _, model := range []any{&team, &other} {
|
||||
if err := db.Create(model).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
schedule := models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(24 * time.Hour), EndAt: now.Add(32 * time.Hour), Status: enums.StatusOk}
|
||||
if err := db.Create(&schedule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, model := range []any{&team, &other} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } }
|
||||
schedule := models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(24*time.Hour), EndAt: now.Add(32*time.Hour), Status: enums.StatusOk}
|
||||
if err := db.Create(&schedule).Error; err != nil { t.Fatal(err) }
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, principal)
|
||||
if len(result.Changes) != 0 {
|
||||
t.Fatal("off-hours team must not be mutated")
|
||||
}
|
||||
if len(result.Changes) != 0 { t.Fatal("off-hours team must not be mutated") }
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 1)
|
||||
if result.Readiness == nil {
|
||||
t.Fatal("missing readiness")
|
||||
}
|
||||
if result.Readiness == nil { t.Fatal("missing readiness") }
|
||||
foundOffHours, foundMissing := false, false
|
||||
for _, step := range result.Readiness.Steps {
|
||||
if step.Key != "schedules" {
|
||||
continue
|
||||
}
|
||||
if step.Key != "schedules" { continue }
|
||||
for _, issue := range step.Issues {
|
||||
if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" {
|
||||
foundOffHours = true
|
||||
}
|
||||
if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" {
|
||||
foundMissing = true
|
||||
}
|
||||
if issue.QuickFixAction != "" {
|
||||
t.Fatal("shift changes require explicit scheduling")
|
||||
if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" { foundOffHours = true }
|
||||
if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" { foundMissing = true }
|
||||
if issue.QuickFixAction != "" { t.Fatal("shift changes require explicit scheduling") }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOffHours || !foundMissing {
|
||||
t.Fatal("missing and off-hours schedules must be distinguished")
|
||||
}
|
||||
if !foundOffHours || !foundMissing { t.Fatal("missing and off-hours schedules must be distinguished") }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) {
|
||||
@@ -347,9 +237,7 @@ func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) {
|
||||
for _, role := range []string{constants.RoleCodeAdmin, constants.RoleCodeSuperAdmin} {
|
||||
principal.Roles = []string{role}
|
||||
for _, action := range []string{services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel} {
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil {
|
||||
t.Fatalf("role %s bypassed explicit permission list for %s", role, action)
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil { t.Fatalf("role %s bypassed explicit permission list for %s", role, action) }
|
||||
}
|
||||
}
|
||||
assertSetupCount(t, db, &models.AgentTeam{}, 0)
|
||||
@@ -359,25 +247,15 @@ func TestSetupReadiness_StatusOnlyPermissionRemainsScoped(t *testing.T) {
|
||||
db := setupSetupReadinessTestDB(t)
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
team := models.AgentTeam{Name: "Existing", Status: enums.StatusOk}
|
||||
if err := db.Create(&team).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&team).Error; err != nil { t.Fatal(err) }
|
||||
profile := models.AgentProfile{UserID: principal.UserID, TeamID: team.ID, AgentCode: "status-only", Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusBusy, AutoAssignEnabled: true, MaxConcurrentCount: 5}
|
||||
if err := db.Create(&profile).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&profile).Error; err != nil { t.Fatal(err) }
|
||||
principal.Permissions = []string{constants.PermissionAgentUpdateStatus.Code}
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal)
|
||||
if len(result.Changes) != 1 || len(result.Changes[0].After) != 1 {
|
||||
t.Fatalf("status-only permission exceeded: %+v", result.Changes)
|
||||
}
|
||||
if len(result.Changes) != 1 || len(result.Changes[0].After) != 1 { t.Fatalf("status-only permission exceeded: %+v", result.Changes) }
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil {
|
||||
t.Fatal("status permission must not authorize config edits")
|
||||
}
|
||||
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { t.Fatal(err) }
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil { t.Fatal("status permission must not authorize config edits") }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) {
|
||||
@@ -385,71 +263,33 @@ func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) {
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
for _, channelID := range []int64{0, 9999} {
|
||||
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channelID}, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"candidate_operators":[]`) {
|
||||
t.Fatalf("null candidates: %s", encoded)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if !strings.Contains(string(encoded), `"candidate_operators":[]`) { t.Fatalf("null candidates: %s", encoded) }
|
||||
}
|
||||
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
var team models.AgentTeam
|
||||
var agent models.AIAgent
|
||||
if err := db.First(&team).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&agent).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&team).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.First(&agent).Error; err != nil { t.Fatal(err) }
|
||||
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) }
|
||||
channel := models.Channel{Name: "Published human-only test route", ChannelID: "human-only-test", ChannelType: "web", AIAgentID: agent.ID, AIAgentRolloutPercent: 100, Status: enums.StatusOk}
|
||||
if err := db.Create(&channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&channel).Error; err != nil { t.Fatal(err) }
|
||||
now := time.Now()
|
||||
if err := db.Create(&models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A configured profile alone is not live staffing. This specific success
|
||||
// fixture supplies an employee account, current permission, and login lease;
|
||||
// other setup fixtures retain their original visitor/permission boundaries.
|
||||
seedSetupSimulationPresence(t, db, principal, now)
|
||||
if err := db.Create(&models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) }
|
||||
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID {
|
||||
t.Fatalf("human-only check failed: %+v", result)
|
||||
}
|
||||
if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil {
|
||||
t.Fatal("unsupported bypass must be explicitly rejected")
|
||||
}
|
||||
if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID { t.Fatalf("human-only check failed: %+v", result) }
|
||||
if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil { t.Fatal("unsupported bypass must be explicitly rejected") }
|
||||
if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil { t.Fatal(err) }
|
||||
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.WillReachOperator || result.FinalOutcomeCode != "ai_execution_not_simulated" {
|
||||
t.Fatalf("AI route must not promise assignment: %+v", result)
|
||||
}
|
||||
if err := db.Model(&channel).Update("ai_agent_rollout_percent", 25).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if result.WillReachOperator || result.FinalOutcomeCode != "ai_execution_not_simulated" { t.Fatalf("AI route must not promise assignment: %+v", result) }
|
||||
if err := db.Model(&channel).Update("ai_agent_rollout_percent", 25).Error; err != nil { t.Fatal(err) }
|
||||
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" {
|
||||
t.Fatalf("partial rollout must not promise assignment: %+v", result)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" { t.Fatalf("partial rollout must not promise assignment: %+v", result) }
|
||||
}
|
||||
|
||||
func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) {
|
||||
@@ -457,27 +297,15 @@ func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) {
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
|
||||
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := db.Callback().Query().Before("gorm:query").Register("fail_post_commit_dispatch", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" {
|
||||
tx.AddError(errors.New("injected dispatch read failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" { tx.AddError(errors.New("injected dispatch read failure")) }
|
||||
}); err != nil { t.Fatal(err) }
|
||||
req.Apply, req.PreviewToken = true, preview.PreviewToken
|
||||
result, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("committed repair must not appear rolled back: %v", err)
|
||||
}
|
||||
if !result.Applied || !slices.Contains(result.WarningCodes, "dispatch_failed") {
|
||||
t.Fatalf("dispatch failure must be visible: %+v", result)
|
||||
}
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} {
|
||||
assertSetupCount(t, db, model, 1)
|
||||
}
|
||||
if err != nil { t.Fatalf("committed repair must not appear rolled back: %v", err) }
|
||||
if !result.Applied || !slices.Contains(result.WarningCodes, "dispatch_failed") { t.Fatalf("dispatch failure must be visible: %+v", result) }
|
||||
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 0)
|
||||
}
|
||||
|
||||
@@ -486,23 +314,15 @@ func TestSetupReadiness_DefaultChannelRequiresPublishedAgent(t *testing.T) {
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
|
||||
if len(result.Changes) != 0 || !slices.Contains(result.WarningCodes, "agent_publication_required") {
|
||||
t.Fatalf("draft agent must not be bound implicitly: %+v", result)
|
||||
}
|
||||
if len(result.Changes) != 0 || !slices.Contains(result.WarningCodes, "agent_publication_required") { t.Fatalf("draft agent must not be bound implicitly: %+v", result) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 0)
|
||||
var agent models.AIAgent
|
||||
if err := db.First(&agent).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&agent).Error; err != nil { t.Fatal(err) }
|
||||
// This unit fixture supplies the publication marker; revision publication itself
|
||||
// belongs to the existing agent publishing service's tests.
|
||||
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) }
|
||||
result = applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
|
||||
if len(result.Changes) != 1 || result.Changes[0].EntityType != "channel" {
|
||||
t.Fatalf("expected reviewed channel create: %+v", result.Changes)
|
||||
}
|
||||
if len(result.Changes) != 1 || result.Changes[0].EntityType != "channel" { t.Fatalf("expected reviewed channel create: %+v", result.Changes) }
|
||||
assertSetupCount(t, db, &models.Channel{}, 1)
|
||||
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
|
||||
}
|
||||
@@ -512,37 +332,6 @@ func TestSetupReadiness_MyProfileRejectsAnotherNamedTarget(t *testing.T) {
|
||||
principal := setupRepairPrincipal(t, db)
|
||||
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
|
||||
var profile models.AgentProfile
|
||||
if err := db.Where("user_id = ?", principal.UserID).First(&profile).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile, EntityID: profile.ID + 1}, principal); err == nil {
|
||||
t.Fatal("my-profile action must not silently substitute a different named target")
|
||||
}
|
||||
}
|
||||
|
||||
func seedSetupSimulationPresence(t *testing.T, db *gorm.DB, principal *dto.AuthPrincipal, now time.Time) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.User{}).Where("id = ?", principal.UserID).Update("user_type", enums.UserTypeEmployee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role := models.Role{Code: "setup-simulation-operator", Status: enums.StatusOk}
|
||||
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
|
||||
session := models.LoginSession{UserID: principal.UserID, Token: "ak_isolated_setup_simulation", ExpiredAt: now.Add(time.Hour)}
|
||||
if err := db.Where("code = ?", permission.Code).FirstOrCreate(&permission).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range []any{&role, &session} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, item := range []any{&models.UserRole{UserID: principal.UserID, RoleID: role.ID}, &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, &models.AgentPresenceLease{ID: "setup-simulation-presence", UserID: principal.UserID, LoginSessionID: session.ID, ExpiresAt: now.Add(time.Minute)}} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
principal.UserType = enums.UserTypeEmployee
|
||||
principal.LoginSessionID = session.ID
|
||||
principal.ExpiresAt = session.ExpiredAt
|
||||
principal.Permissions = append(principal.Permissions, permission.Code)
|
||||
if err := db.Where("user_id = ?", principal.UserID).First(&profile).Error; err != nil { t.Fatal(err) }
|
||||
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile, EntityID: profile.ID + 1}, principal); err == nil { t.Fatal("my-profile action must not silently substitute a different named target") }
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSupportReviewHalfHourDSTAndMissingWholePeriod(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, start, end, at, next string
|
||||
open bool
|
||||
}{
|
||||
{"half-hour gap", "02:10", "03:00", "2026-10-03T15:29:59Z", "2026-10-03T15:30:00Z", false},
|
||||
{"gap opening", "02:10", "03:00", "2026-10-03T15:30:00Z", "", true},
|
||||
{"whole period absent", "02:10", "02:20", "2026-10-03T15:30:00Z", "2026-10-10T15:10:00Z", false},
|
||||
{"fold first occurrence", "01:45", "01:50", "2026-04-04T14:45:00Z", "", true},
|
||||
{"fold second occurrence", "01:45", "01:50", "2026-04-04T15:19:59Z", "", true},
|
||||
{"fold half-open end", "01:45", "01:50", "2026-04-04T15:20:00Z", "2026-04-11T15:15:00Z", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calendar := calendarForTest("Australia/Lord_Howe", weeklyForTest(7, tc.start, tc.end))
|
||||
got, err := evaluateRecurringSupportCalendar(calendar, instantForTest(tc.at))
|
||||
if err != nil || got.Open != tc.open || supportTimeString(got.NextOpeningAt) != tc.next {
|
||||
t.Fatalf("got open=%v next=%s error=%v; want open=%v next=%s", got.Open, supportTimeString(got.NextOpeningAt), err, tc.open, tc.next)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportReviewNextOpeningKeepsItsTimezone(t *testing.T) {
|
||||
f, p, ai := newAvailabilityFixture(t)
|
||||
secondTeam := models.AgentTeam{Name: "Later opening", Status: enums.StatusOk}
|
||||
if err := f.db.Create(&secondTeam).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.db.Model(&ai).Update("team_ids", fmt.Sprintf("%d,%d", p.TeamID, secondTeam.ID)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
teamID int64
|
||||
zone string
|
||||
days int
|
||||
}{{p.TeamID, "Pacific/Auckland", 2}, {secondTeam.ID, "America/New_York", 5}} {
|
||||
loc, err := time.LoadLocation(item.zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
date := f.now.In(loc).AddDate(0, 0, item.days).Format(time.DateOnly)
|
||||
exceptions, err := json.Marshal([]request.SupportCalendarException{{Date: date, Periods: []request.SupportCalendarPeriod{{Start: "09:00", End: "10:00"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calendar := &models.SupportCalendar{TeamID: item.teamID, Mode: "recurring", Timezone: item.zone, WeeklyJSON: "[]", ExceptionsJSON: string(exceptions)}
|
||||
if err := repositories.SupportAvailabilityRepository.SaveCalendar(f.db, calendar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want, err := SupportCalendarService.Evaluate(f.db, p.TeamID, f.now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Available || got.Reason != "outside_hours" || got.NextOpeningAt != supportTimeString(want.NextOpeningAt) || got.Timezone != want.Timezone {
|
||||
t.Fatalf("next opening and timezone must belong to the same team: got %+v, want next=%s timezone=%s", got, supportTimeString(want.NextOpeningAt), want.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportReviewFallbackGuardUsesTransactionConnection(t *testing.T) {
|
||||
f, p, ai := newAvailabilityFixture(t)
|
||||
if err := f.db.Model(&ai).Update("team_ids", "").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}
|
||||
if err := f.db.Create(&conversation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := f.db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err = f.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
_, err := SupportAvailabilityService.GuardAssignment(tx, p.UserID, true, &conversation, f.now)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty AI team IDs must resolve without escaping the held transaction: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportReviewDistinctSessionRevocationAndOwnerBinding(t *testing.T) {
|
||||
f, p, _ := newAvailabilityFixture(t)
|
||||
second := models.LoginSession{UserID: p.UserID, Token: "ak_test_only_second_session", ExpiredAt: f.now.Add(time.Hour)}
|
||||
if err := f.db.Create(&second).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lease := models.AgentPresenceLease{ID: "separate-login", UserID: p.UserID, LoginSessionID: second.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)}
|
||||
if err := f.db.Create(&lease).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.db.Model(&f.session).Update("revoked_at", f.now).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now)
|
||||
if err != nil || !users[p.UserID] {
|
||||
t.Fatalf("revoking one login must not hide another valid login: %v %v", users, err)
|
||||
}
|
||||
if err := f.db.Model(&second).Update("user_id", p.UserID+1000).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now)
|
||||
if err != nil || users[p.UserID] {
|
||||
t.Fatalf("lease must remain bound to login-session owner: %v %v", users, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportReviewGuardRejectsDisabledProfileDespiteLiveSession(t *testing.T) {
|
||||
f, p, ai := newAvailabilityFixture(t)
|
||||
if err := f.db.Model(&p).Update("status", enums.StatusDisabled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}
|
||||
if err := f.db.Create(&conversation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := f.db.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := SupportAvailabilityService.GuardAssignment(tx, p.UserID, false, &conversation, f.now)
|
||||
return err
|
||||
})
|
||||
if !errors.Is(err, errSupportAssignmentIneligible) {
|
||||
t.Fatalf("live session must not bypass disabled profile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportReviewEffectiveStatusFilterAndPagination(t *testing.T) {
|
||||
f, p, _ := newAvailabilityFixture(t)
|
||||
before, now, after := f.now.Add(-time.Hour), f.now, f.now.Add(time.Hour)
|
||||
earlier, later := before.Add(-time.Hour), after.Add(time.Hour)
|
||||
cases := []struct {
|
||||
name string
|
||||
status, fallback enums.ServiceStatus
|
||||
start, end *time.Time
|
||||
want enums.ServiceStatus
|
||||
}{
|
||||
{"ordinary busy ignores absence", enums.ServiceStatusBusy, enums.ServiceStatusIdle, &before, &after, enums.ServiceStatusBusy},
|
||||
{"future vacation returns idle", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &after, &later, enums.ServiceStatusIdle},
|
||||
{"active vacation", enums.ServiceStatusVacation, enums.ServiceStatusBusy, &before, &after, enums.ServiceStatusVacation},
|
||||
{"inclusive vacation start", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &now, &after, enums.ServiceStatusVacation},
|
||||
{"exclusive vacation end", enums.ServiceStatusVacation, enums.ServiceStatusBreak, &before, &now, enums.ServiceStatusBreak},
|
||||
{"expired vacation returns idle", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &earlier, &before, enums.ServiceStatusIdle},
|
||||
{"missing bounds safe fallback", enums.ServiceStatusVacation, enums.ServiceStatusUnavailable, nil, nil, enums.ServiceStatusUnavailable},
|
||||
{"invalid return status safe fallback", enums.ServiceStatusVacation, enums.ServiceStatusVacation, nil, nil, enums.ServiceStatusUnavailable},
|
||||
}
|
||||
expected := map[enums.ServiceStatus][]int64{enums.ServiceStatusIdle: {p.ID}}
|
||||
for i, tc := range cases {
|
||||
profile := models.AgentProfile{UserID: p.UserID + int64(i) + 10, TeamID: p.TeamID, AgentCode: fmt.Sprintf("review-%d", i), DisplayName: tc.name, Status: enums.StatusOk, ServiceStatus: tc.status, AvailabilityReturnStatus: tc.fallback, AbsenceStartAt: tc.start, AbsenceEndAt: tc.end}
|
||||
if err := f.db.Create(&profile).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := effectiveSupportStatus(profile, now); got != tc.want {
|
||||
t.Fatalf("%s: policy returned %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
expected[tc.want] = append(expected[tc.want], profile.ID)
|
||||
}
|
||||
for _, status := range enums.ServiceStatusValues {
|
||||
wantIDs := expected[status]
|
||||
for page := 1; page <= len(wantIDs)+1; page++ {
|
||||
cnd := sqls.NewCnd().Asc("id").Page(page, 1)
|
||||
repositories.SupportAvailabilityRepository.EffectiveStatusCondition(cnd, status, now)
|
||||
got, paging := repositories.AgentProfileRepository.FindPageByCnd(f.db, cnd)
|
||||
if paging.Total != int64(len(wantIDs)) {
|
||||
t.Fatalf("status %d page %d: total %d, want %d", status, page, paging.Total, len(wantIDs))
|
||||
}
|
||||
if page > len(wantIDs) {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("status %d: final page was not empty: %+v", status, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != wantIDs[page-1] {
|
||||
t.Fatalf("status %d page %d: got %+v, want id=%d", status, page, got, wantIDs[page-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"99", "-1", "not-a-status"} {
|
||||
if err := AgentProfileService.FilterEffectiveServiceStatus(sqls.NewCnd(), value); err == nil {
|
||||
t.Fatalf("accepted invalid effective status %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/repositories"
|
||||
"errors"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errSupportAssignmentIneligible = errors.New("support assignment ineligible")
|
||||
|
||||
var SupportAvailabilityService = &supportAvailabilityService{}
|
||||
|
||||
type supportAvailabilityService struct{}
|
||||
type supportEligibility struct {
|
||||
Eligible, Online bool
|
||||
Reason string
|
||||
EffectiveStatus enums.ServiceStatus
|
||||
Calendar supportCalendarEvaluation
|
||||
ActiveCount int
|
||||
}
|
||||
|
||||
func effectiveSupportStatus(profile models.AgentProfile, now time.Time) enums.ServiceStatus {
|
||||
if profile.ServiceStatus != enums.ServiceStatusVacation {
|
||||
return profile.ServiceStatus
|
||||
}
|
||||
if profile.AbsenceStartAt != nil && profile.AbsenceEndAt != nil && !now.Before(*profile.AbsenceStartAt) && now.Before(*profile.AbsenceEndAt) {
|
||||
return enums.ServiceStatusVacation
|
||||
}
|
||||
if profile.AvailabilityReturnStatus >= enums.ServiceStatusIdle && profile.AvailabilityReturnStatus <= enums.ServiceStatusUnavailable {
|
||||
return profile.AvailabilityReturnStatus
|
||||
}
|
||||
return enums.ServiceStatusUnavailable
|
||||
}
|
||||
|
||||
// eligibilityReason is the only policy decision for new human assignment. All
|
||||
// automatic/manual/status/readiness callers provide facts to this evaluator.
|
||||
func eligibilityReason(profile models.AgentProfile, user, team bool, online bool, calendarOpen bool, activeCount int, automatic bool, now time.Time) string {
|
||||
switch {
|
||||
case profile.Status != enums.StatusOk:
|
||||
return "profile_disabled"
|
||||
case !user:
|
||||
return "user_disabled"
|
||||
case !team:
|
||||
return "team_disabled"
|
||||
case !calendarOpen:
|
||||
return "outside_hours"
|
||||
case !online:
|
||||
return "offline"
|
||||
}
|
||||
switch effectiveSupportStatus(profile, now) {
|
||||
case enums.ServiceStatusBusy:
|
||||
return "busy"
|
||||
case enums.ServiceStatusBreak:
|
||||
return "break"
|
||||
case enums.ServiceStatusUnavailable:
|
||||
return "unavailable"
|
||||
case enums.ServiceStatusVacation:
|
||||
return "vacation"
|
||||
case enums.ServiceStatusIdle:
|
||||
default:
|
||||
return "unavailable"
|
||||
}
|
||||
if automatic && !profile.AutoAssignEnabled {
|
||||
return "auto_assign_disabled"
|
||||
}
|
||||
if profile.MaxConcurrentCount < 0 || profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount {
|
||||
return "capacity"
|
||||
}
|
||||
return "eligible"
|
||||
}
|
||||
func (s *supportAvailabilityService) Evaluate(db *gorm.DB, profile models.AgentProfile, automatic bool, now time.Time, excludeConversationID int64) (supportEligibility, error) {
|
||||
ret := supportEligibility{EffectiveStatus: effectiveSupportStatus(profile, now)}
|
||||
user, err := repositories.SupportAvailabilityRepository.User(db, profile.UserID)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
team, err := repositories.SupportAvailabilityRepository.Team(db, profile.TeamID)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
calendar, err := SupportCalendarService.Evaluate(db, profile.TeamID, now)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
ret.Calendar = calendar
|
||||
online, err := s.onlineUsers(db, []int64{profile.UserID}, now)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
ret.Online = online[profile.UserID]
|
||||
count, err := repositories.SupportAvailabilityRepository.ActiveCount(db, profile.UserID, excludeConversationID)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
ret.ActiveCount = count
|
||||
userEnabled := user != nil && user.DeletedAt == nil && user.Status == enums.StatusOk && user.UserType == enums.UserTypeEmployee
|
||||
ret.Reason = eligibilityReason(profile, userEnabled, team != nil && team.Status == enums.StatusOk, ret.Online, calendar.Open, count, automatic, now)
|
||||
ret.Eligible = ret.Reason == "eligible"
|
||||
return ret, nil
|
||||
}
|
||||
func (s *supportAvailabilityService) onlineUsers(db *gorm.DB, userIDs []int64, now time.Time) (map[int64]bool, error) {
|
||||
online, err := repositories.SupportAvailabilityRepository.OnlineUsers(db, userIDs, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Current account/role/permission state is checked on every use, not only on
|
||||
// lease creation, so revoked privileges cannot linger for the lease lifetime.
|
||||
for userID := range online {
|
||||
user, err := repositories.SupportAvailabilityRepository.User(db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil || user.Status != enums.StatusOk || user.DeletedAt != nil || user.UserType != enums.UserTypeEmployee {
|
||||
delete(online, userID)
|
||||
continue
|
||||
}
|
||||
_, permissions, err := AuthService.loadUserAuthScope(db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.Contains(permissions, constants.PermissionConversationView.Code) {
|
||||
delete(online, userID)
|
||||
}
|
||||
}
|
||||
return online, nil
|
||||
}
|
||||
func (s *supportAvailabilityService) ForUser(userID int64, automatic bool) (*response.SupportAvailabilityResponse, error) {
|
||||
profile, err := repositories.SupportAvailabilityRepository.ProfileByUser(sqls.DB(), userID)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.build(sqls.DB(), *profile, automatic, time.Now(), 0)
|
||||
}
|
||||
func (s *supportAvailabilityService) build(db *gorm.DB, profile models.AgentProfile, automatic bool, now time.Time, excludeConversationID int64) (*response.SupportAvailabilityResponse, error) {
|
||||
eligibility, err := s.Evaluate(db, profile, automatic, now, excludeConversationID)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
calendar, err := SupportCalendarService.get(db, profile.TeamID)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
return &response.SupportAvailabilityResponse{UserID: profile.UserID, ProfileID: profile.ID, TeamID: profile.TeamID, DisplayName: profile.DisplayName, ServiceStatus: profile.ServiceStatus, ReturnServiceStatus: profile.AvailabilityReturnStatus, EffectiveServiceStatus: eligibility.EffectiveStatus, AbsenceStartAt: supportTimeString(profile.AbsenceStartAt), AbsenceEndAt: supportTimeString(profile.AbsenceEndAt), Online: eligibility.Online, Eligible: eligibility.Eligible, Reason: eligibility.Reason, Calendar: *calendar, CalendarOpen: eligibility.Calendar.Open, NextOpeningAt: supportTimeString(eligibility.Calendar.NextOpeningAt)}, nil
|
||||
}
|
||||
func (s *supportAvailabilityService) List(conversationID int64, operator *dto.AuthPrincipal) ([]response.SupportAvailabilityResponse, error) {
|
||||
var teamIDs []int64
|
||||
if conversationID > 0 {
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
if !slices.Contains(operator.Permissions, constants.PermissionConversationView.Code) {
|
||||
return nil, errorsx.ForbiddenI18n("error.e0225")
|
||||
}
|
||||
if conversation.Status == enums.IMConversationStatusActive && !ConversationService.canTransferConversation(conversation, operator) {
|
||||
return nil, errorsx.ForbiddenI18n("error.e0223")
|
||||
}
|
||||
ai := AIAgentService.Get(conversation.AIAgentID)
|
||||
if ai == nil || ai.Status != enums.StatusOk {
|
||||
teamIDs = []int64{}
|
||||
} else {
|
||||
teamIDs = resolveDispatchTeamIDs(ai.TeamIDs)
|
||||
}
|
||||
}
|
||||
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
result := make([]response.SupportAvailabilityResponse, 0, len(profiles))
|
||||
now := time.Now()
|
||||
for _, profile := range profiles {
|
||||
item, err := s.build(sqls.DB(), profile, false, now, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func validateSupportAbsence(req request.SupportAvailabilityRequest) (*time.Time, *time.Time, error) {
|
||||
if !enums.IsValidServiceStatus(req.ServiceStatus) {
|
||||
return nil, nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidStatus")
|
||||
}
|
||||
if req.ServiceStatus != enums.ServiceStatusVacation {
|
||||
return nil, nil, nil
|
||||
}
|
||||
start, err1 := time.Parse(time.RFC3339, req.AbsenceStartAt)
|
||||
end, err2 := time.Parse(time.RFC3339, req.AbsenceEndAt)
|
||||
if err1 != nil || err2 != nil || !end.After(start) {
|
||||
return nil, nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidAbsence")
|
||||
}
|
||||
return &start, &end, nil
|
||||
}
|
||||
func (s *supportAvailabilityService) Update(userID int64, req request.SupportAvailabilityRequest, operator *dto.AuthPrincipal) (*response.SupportAvailabilityResponse, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
start, end, err := validateSupportAbsence(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
profile, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, userID)
|
||||
if err != nil {
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.noProfile")
|
||||
}
|
||||
user, err := repositories.SupportAvailabilityRepository.User(ctx.Tx, userID)
|
||||
if err != nil {
|
||||
return supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
if profile.Status != enums.StatusOk || user == nil || user.Status != enums.StatusOk || user.DeletedAt != nil || user.UserType != enums.UserTypeEmployee {
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.noProfile")
|
||||
}
|
||||
returnStatus := profile.AvailabilityReturnStatus
|
||||
if req.ServiceStatus == enums.ServiceStatusVacation && profile.ServiceStatus != enums.ServiceStatusVacation {
|
||||
returnStatus = profile.ServiceStatus
|
||||
}
|
||||
if req.ServiceStatus != enums.ServiceStatusVacation {
|
||||
returnStatus = req.ServiceStatus
|
||||
}
|
||||
return supportAvailabilityPersistenceError(repositories.AgentProfileRepository.Updates(ctx.Tx, profile.ID, map[string]any{"service_status": req.ServiceStatus, "availability_return_status": returnStatus, "absence_start_at": start, "absence_end_at": end, "last_status_at": time.Now(), "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now()}))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
return s.ForUser(userID, true)
|
||||
}
|
||||
func (s *supportAvailabilityService) GuardAssignment(db *gorm.DB, userID int64, automatic bool, conversation *models.Conversation, now time.Time) (*models.AgentProfile, error) {
|
||||
profile, err := repositories.SupportAvailabilityRepository.LockProfile(db, userID)
|
||||
if err != nil {
|
||||
return nil, errSupportAssignmentIneligible
|
||||
}
|
||||
ai := repositories.AIAgentRepository.Get(db, conversation.AIAgentID)
|
||||
if ai == nil || ai.Status != enums.StatusOk {
|
||||
return nil, errSupportAssignmentIneligible
|
||||
}
|
||||
// Empty explicit team IDs mean all enabled teams. The shared evaluator
|
||||
// checks this profile team using the transaction DB, not sqls.DB().
|
||||
teamIDs := orderedPositiveIDs(ai.TeamIDs)
|
||||
if len(teamIDs) > 0 && !slices.Contains(teamIDs, profile.TeamID) {
|
||||
return nil, errSupportAssignmentIneligible
|
||||
}
|
||||
eligibility, err := s.Evaluate(db, *profile, automatic, now, conversation.ID)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
if !eligibility.Eligible {
|
||||
return nil, errSupportAssignmentIneligible
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
func (s *supportAvailabilityService) PublicStatus(channel *models.Channel) (*response.PublicSupportStatusResponse, error) {
|
||||
result := &response.PublicSupportStatusResponse{Reason: "temporarily_absent", Timezone: "UTC"}
|
||||
if channel == nil || channel.Status != enums.StatusOk {
|
||||
return result, nil
|
||||
}
|
||||
ai := AIAgentService.Get(channel.AIAgentID)
|
||||
if ai == nil || ai.Status != enums.StatusOk {
|
||||
return result, nil
|
||||
}
|
||||
now := time.Now()
|
||||
teams := resolveDispatchTeamIDs(ai.TeamIDs)
|
||||
anyOpen := false
|
||||
enabledTeamCount := 0
|
||||
atCapacity := false
|
||||
var next *time.Time
|
||||
nextTimezone := ""
|
||||
for _, id := range teams {
|
||||
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), id)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
if team == nil || team.Status != enums.StatusOk {
|
||||
continue
|
||||
}
|
||||
enabledTeamCount++
|
||||
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), id, now)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
result.Timezone = calendar.Timezone
|
||||
anyOpen = anyOpen || calendar.Open
|
||||
if calendar.NextOpeningAt != nil && (next == nil || calendar.NextOpeningAt.Before(*next)) {
|
||||
next = calendar.NextOpeningAt
|
||||
nextTimezone = calendar.Timezone
|
||||
}
|
||||
}
|
||||
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teams)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
eligibility, err := s.Evaluate(sqls.DB(), profile, true, now, 0)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
if eligibility.Eligible {
|
||||
result.Available = true
|
||||
result.Reason = "available"
|
||||
result.Timezone = eligibility.Calendar.Timezone
|
||||
return result, nil
|
||||
}
|
||||
atCapacity = atCapacity || eligibility.Reason == "capacity"
|
||||
}
|
||||
if enabledTeamCount > 0 && !anyOpen {
|
||||
result.Reason = "outside_hours"
|
||||
result.NextOpeningAt = supportTimeString(next)
|
||||
if nextTimezone != "" {
|
||||
result.Timezone = nextTimezone
|
||||
}
|
||||
} else if atCapacity {
|
||||
result.Reason = "capacity"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func calendarForTest(zone string, periods ...request.SupportCalendarWeeklyPeriod) *response.SupportCalendarResponse {
|
||||
return &response.SupportCalendarResponse{SupportCalendarRequest: request.SupportCalendarRequest{TeamID: 1, Mode: "recurring", Timezone: zone, Weekly: periods, Exceptions: []request.SupportCalendarException{}}, EffectiveTimezone: zone}
|
||||
}
|
||||
func weeklyForTest(day int, start, end string) request.SupportCalendarWeeklyPeriod {
|
||||
return request.SupportCalendarWeeklyPeriod{Weekday: day, SupportCalendarPeriod: request.SupportCalendarPeriod{Start: start, End: end}}
|
||||
}
|
||||
func instantForTest(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
func TestSupportCalendarSplitOvernightExceptionAndNextOpening(t *testing.T) {
|
||||
c := calendarForTest("Europe/Berlin", weeklyForTest(1, "09:00", "12:00"), weeklyForTest(1, "13:00", "17:00"), weeklyForTest(5, "22:00", "06:00"))
|
||||
tests := []struct {
|
||||
at string
|
||||
open bool
|
||||
next string
|
||||
}{
|
||||
{"2026-10-12T08:00:00Z", true, ""}, {"2026-10-12T10:30:00Z", false, "2026-10-12T11:00:00Z"},
|
||||
{"2026-10-16T21:00:00Z", true, ""}, {"2026-10-17T03:59:59Z", true, ""}, {"2026-10-17T04:00:00Z", false, "2026-10-19T07:00:00Z"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := evaluateRecurringSupportCalendar(c, instantForTest(tt.at))
|
||||
if err != nil || got.Open != tt.open || supportTimeString(got.NextOpeningAt) != tt.next {
|
||||
t.Errorf("%s: %+v %v next %s", tt.at, got, err, supportTimeString(got.NextOpeningAt))
|
||||
}
|
||||
}
|
||||
c.Exceptions = []request.SupportCalendarException{{Date: "2026-10-17", Closed: true}}
|
||||
got, err := evaluateRecurringSupportCalendar(c, instantForTest("2026-10-17T00:00:00+02:00"))
|
||||
if err != nil || got.Open {
|
||||
t.Fatalf("holiday must suppress overnight carry: %+v %v", got, err)
|
||||
}
|
||||
c.Exceptions = []request.SupportCalendarException{{Date: "2026-10-17", Periods: []request.SupportCalendarPeriod{{Start: "10:00", End: "11:00"}}}}
|
||||
got, err = evaluateRecurringSupportCalendar(c, instantForTest("2026-10-17T00:00:00+02:00"))
|
||||
if err != nil || supportTimeString(got.NextOpeningAt) != "2026-10-17T08:00:00Z" {
|
||||
t.Fatalf("date override: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
func TestSupportCalendarDSTGapAndFold(t *testing.T) {
|
||||
spring := calendarForTest("America/New_York", weeklyForTest(7, "02:30", "04:00"))
|
||||
got, err := evaluateRecurringSupportCalendar(spring, instantForTest("2026-03-08T06:59:00Z"))
|
||||
if err != nil || got.Open || supportTimeString(got.NextOpeningAt) != "2026-03-08T07:00:00Z" {
|
||||
t.Fatalf("gap boundary: %+v %v", got, err)
|
||||
}
|
||||
got, err = evaluateRecurringSupportCalendar(spring, instantForTest("2026-03-08T07:00:00Z"))
|
||||
if err != nil || !got.Open {
|
||||
t.Fatalf("gap opening: %+v %v", got, err)
|
||||
}
|
||||
fold := calendarForTest("America/New_York", weeklyForTest(7, "01:30", "01:45"))
|
||||
for _, at := range []string{"2026-11-01T05:30:00Z", "2026-11-01T06:15:00Z", "2026-11-01T06:44:59Z"} {
|
||||
got, err = evaluateRecurringSupportCalendar(fold, instantForTest(at))
|
||||
if err != nil || !got.Open {
|
||||
t.Errorf("fold %s: %+v %v", at, got, err)
|
||||
}
|
||||
}
|
||||
got, err = evaluateRecurringSupportCalendar(fold, instantForTest("2026-11-01T06:45:00Z"))
|
||||
if err != nil || got.Open {
|
||||
t.Fatalf("fold end: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
func TestSupportCalendarValidationAndBoundedUnknownOpening(t *testing.T) {
|
||||
for _, req := range []request.SupportCalendarRequest{
|
||||
{TeamID: 0, Mode: "recurring"}, {TeamID: 1, Mode: "recurring", Timezone: "Local"}, {TeamID: 1, Mode: "recurring", Timezone: "Not/AZone"},
|
||||
{TeamID: 1, Mode: "recurring", Weekly: []request.SupportCalendarWeeklyPeriod{weeklyForTest(1, "09:00", "09:00")}},
|
||||
{TeamID: 1, Mode: "recurring", Exceptions: []request.SupportCalendarException{{Date: "2026-02-30", Closed: true}}},
|
||||
} {
|
||||
if validateSupportCalendar(req) == nil {
|
||||
t.Errorf("accepted invalid config: %+v", req)
|
||||
}
|
||||
}
|
||||
c := calendarForTest("UTC")
|
||||
got, err := evaluateRecurringSupportCalendar(c, time.Now())
|
||||
if err != nil || got.Open || got.NextOpeningAt != nil {
|
||||
t.Fatalf("empty calendar invented opening: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityEffectiveAbsenceAndUnlimitedCapacity(t *testing.T) {
|
||||
start := instantForTest("2026-10-17T09:00:00Z")
|
||||
end := start.Add(48 * time.Hour)
|
||||
p := models.AgentProfile{Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusVacation, AvailabilityReturnStatus: enums.ServiceStatusBusy, AbsenceStartAt: &start, AbsenceEndAt: &end, AutoAssignEnabled: true}
|
||||
for _, tt := range []struct {
|
||||
at time.Time
|
||||
want enums.ServiceStatus
|
||||
}{{start.Add(-time.Second), enums.ServiceStatusBusy}, {start, enums.ServiceStatusVacation}, {end.Add(-time.Second), enums.ServiceStatusVacation}, {end, enums.ServiceStatusBusy}} {
|
||||
if got := effectiveSupportStatus(p, tt.at); got != tt.want {
|
||||
t.Errorf("effective at %v: %v", tt.at, got)
|
||||
}
|
||||
}
|
||||
p.ServiceStatus = enums.ServiceStatusIdle
|
||||
p.MaxConcurrentCount = 0
|
||||
if got := eligibilityReason(p, true, true, true, true, 999, true, start); got != "eligible" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
p.MaxConcurrentCount = 1
|
||||
if got := eligibilityReason(p, true, true, true, true, 1, true, start); got != "capacity" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
p.MaxConcurrentCount = 0
|
||||
p.AutoAssignEnabled = false
|
||||
if got := eligibilityReason(p, true, true, true, true, 0, true, start); got != "auto_assign_disabled" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := eligibilityReason(p, true, true, true, true, 0, false, start); got != "eligible" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
p.ReceiveOfflineMessage = true
|
||||
if got := eligibilityReason(p, true, true, false, true, 0, false, start); got != "offline" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityAbsenceRequiresOffsetAndBounds(t *testing.T) {
|
||||
for _, req := range []request.SupportAvailabilityRequest{{ServiceStatus: 4}, {ServiceStatus: 4, AbsenceStartAt: "2026-10-16T10:00", AbsenceEndAt: "2026-10-17T10:00"}, {ServiceStatus: 4, AbsenceStartAt: "2026-10-17T10:00:00Z", AbsenceEndAt: "2026-10-16T10:00:00Z"}, {ServiceStatus: 99}} {
|
||||
if _, _, err := validateSupportAbsence(req); err == nil {
|
||||
t.Fatalf("accepted %+v", req)
|
||||
}
|
||||
}
|
||||
}
|
||||
func newAvailabilityFixture(t *testing.T) (*authSessionFixture, models.AgentProfile, models.AIAgent) {
|
||||
t.Helper()
|
||||
f := newWsAuthFixture(t, true)
|
||||
if err := f.db.AutoMigrate(&models.AgentPresenceLease{}, &models.SupportCalendar{}, &models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Conversation{}, &models.ConversationAssignment{}, &models.ConversationEventLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
team := models.AgentTeam{Name: "Support", Status: enums.StatusOk}
|
||||
if err := f.db.Create(&team).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := models.AgentProfile{UserID: f.user.ID, TeamID: team.ID, AgentCode: "test", DisplayName: "Test", Status: enums.StatusOk, AutoAssignEnabled: true, MaxConcurrentCount: 1}
|
||||
ai := models.AIAgent{Name: "Test", TeamIDs: fmt.Sprint(team.ID), Status: enums.StatusOk}
|
||||
weekly := make([]request.SupportCalendarWeeklyPeriod, 0, 7)
|
||||
for day := 1; day <= 7; day++ {
|
||||
weekly = append(weekly, weeklyForTest(day, "00:00", "24:00"))
|
||||
}
|
||||
raw, _ := json.Marshal(weekly)
|
||||
for _, item := range []any{&p, &ai, &models.SupportCalendar{TeamID: team.ID, Mode: "recurring", Timezone: "UTC", WeeklyJSON: string(raw), ExceptionsJSON: "[]"}, &models.AgentPresenceLease{ID: "one", UserID: f.user.ID, LoginSessionID: f.session.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)}} {
|
||||
if err := f.db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return f, p, ai
|
||||
}
|
||||
func TestSupportAvailabilityPresenceMultiTabAndRevocation(t *testing.T) {
|
||||
f, p, _ := newAvailabilityFixture(t)
|
||||
lease := models.AgentPresenceLease{ID: "second", UserID: p.UserID, LoginSessionID: f.session.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)}
|
||||
if err := f.db.Create(&lease).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.SupportAvailabilityRepository.EndPresence(f.db, "one", f.now.Add(agentPresenceDisconnectGrace)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now.Add(20*time.Second))
|
||||
if err != nil || !users[p.UserID] {
|
||||
t.Fatalf("closing one tab hid another: %v %v", users, err)
|
||||
}
|
||||
users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now.Add(agentPresenceLeaseTTL))
|
||||
if err != nil || users[p.UserID] {
|
||||
t.Fatalf("crashed sessions outlived TTL: %v %v", users, err)
|
||||
}
|
||||
if err := f.db.Model(&f.session).Update("revoked_at", f.now).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now)
|
||||
if err != nil || users[p.UserID] {
|
||||
t.Fatalf("revoked session still present: %v %v", users, err)
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityPresencePermissionAndDisabledAccount(t *testing.T) {
|
||||
for _, column := range []string{"permission", "disabled", "deleted", "expired"} {
|
||||
t.Run(column, func(t *testing.T) {
|
||||
f, p, _ := newAvailabilityFixture(t)
|
||||
switch column {
|
||||
case "permission":
|
||||
f.db.Delete(&f.permission)
|
||||
case "disabled":
|
||||
f.db.Model(&f.user).Update("status", enums.StatusDisabled)
|
||||
case "deleted":
|
||||
f.db.Model(&f.user).Update("deleted_at", f.now)
|
||||
case "expired":
|
||||
f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second))
|
||||
}
|
||||
got, err := SupportAvailabilityService.Evaluate(f.db, p, true, f.now, 0)
|
||||
if err != nil || got.Eligible || got.Online {
|
||||
t.Fatalf("%s: %+v %v", column, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityAbsenceDoesNotChangeExistingConversations(t *testing.T) {
|
||||
f, p, ai := newAvailabilityFixture(t)
|
||||
existing := models.Conversation{AIAgentID: ai.ID, CurrentAssigneeID: p.UserID, CurrentTeamID: p.TeamID, Status: enums.IMConversationStatusActive}
|
||||
if err := f.db.Create(&existing).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := request.SupportAvailabilityRequest{ServiceStatus: enums.ServiceStatusVacation, AbsenceStartAt: f.now.Add(-time.Minute).Format(time.RFC3339), AbsenceEndAt: f.now.Add(time.Hour).Format(time.RFC3339)}
|
||||
result, err := SupportAvailabilityService.Update(p.UserID, req, f.principal)
|
||||
if err != nil || !result.Online || result.Eligible || result.EffectiveServiceStatus != enums.ServiceStatusVacation {
|
||||
t.Fatalf("vacation: %+v %v", result, err)
|
||||
}
|
||||
var current models.Conversation
|
||||
if err := f.db.First(¤t, existing.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Status != existing.Status || current.CurrentAssigneeID != existing.CurrentAssigneeID {
|
||||
t.Fatal("vacation changed existing assignment")
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityLegacyCalendarIsNotReinterpreted(t *testing.T) {
|
||||
f, p, _ := newAvailabilityFixture(t)
|
||||
if err := f.db.Where("team_id = ?", p.TeamID).Delete(&models.SupportCalendar{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := f.now.Add(-time.Hour)
|
||||
end := f.now.Add(time.Hour)
|
||||
schedule := models.AgentTeamSchedule{TeamID: p.TeamID, StartAt: start, EndAt: end, Status: enums.StatusOk}
|
||||
if err := f.db.Create(&schedule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
global := models.SupportCalendar{TeamID: 0, Timezone: "Pacific/Auckland", Mode: "recurring", WeeklyJSON: "[]", ExceptionsJSON: "[]"}
|
||||
if err := f.db.Create(&global).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SupportCalendarService.Evaluate(f.db, p.TeamID, f.now)
|
||||
if err != nil || !got.Open || got.Mode != "legacy" || got.Timezone != time.Local.String() {
|
||||
t.Fatalf("legacy changed: %+v %v", got, err)
|
||||
}
|
||||
c := models.SupportCalendar{TeamID: p.TeamID, Mode: "recurring", Timezone: "", WeeklyJSON: "[]", ExceptionsJSON: "[]"}
|
||||
if err := f.db.Create(&c).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = SupportCalendarService.Evaluate(f.db, p.TeamID, f.now)
|
||||
if err != nil || got.Open || got.Timezone != "Pacific/Auckland" {
|
||||
t.Fatalf("explicit opt-in ignored: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityGuardPreventsCapacityAndStaleVacation(t *testing.T) {
|
||||
f, p, ai := newAvailabilityFixture(t)
|
||||
pending := []models.Conversation{{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}, {AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}}
|
||||
if err := f.db.Create(&pending).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := f.db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan *models.Conversation, 2)
|
||||
errs := make(chan error, 2)
|
||||
for _, conversation := range pending {
|
||||
wg.Add(1)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
got, err := ConversationDispatchService.tryAssignConversation(id, p, "test")
|
||||
results <- got
|
||||
errs <- err
|
||||
}(conversation.ID)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
count := 0
|
||||
for got := range results {
|
||||
if got != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("capacity=1 admitted %d", count)
|
||||
}
|
||||
// A candidate selected before the operator changes availability cannot commit.
|
||||
if err := f.db.Model(&models.Conversation{}).Where("current_assignee_id = ?", p.UserID).Update("status", enums.IMConversationStatusClosed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, end := f.now.Add(-time.Hour), f.now.Add(time.Hour)
|
||||
if err := f.db.Model(&p).Updates(map[string]any{"service_status": enums.ServiceStatusVacation, "absence_start_at": start, "absence_end_at": end}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}
|
||||
f.db.Create(&third)
|
||||
got, err := ConversationDispatchService.tryAssignConversation(third.ID, p, "stale")
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("stale vacation assigned: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
func TestSupportAvailabilityPublicStatusIsChannelScopedAndMinimal(t *testing.T) {
|
||||
f, p, ai := newAvailabilityFixture(t)
|
||||
channel := &models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk}
|
||||
got, err := SupportAvailabilityService.PublicStatus(channel)
|
||||
if err != nil || !got.Available || got.Reason != "available" {
|
||||
t.Fatalf("status %+v %v", got, err)
|
||||
}
|
||||
if err := f.db.Model(&p).Update("service_status", enums.ServiceStatusBusy).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = SupportAvailabilityService.PublicStatus(channel)
|
||||
if err != nil || got.Available || got.Reason != "temporarily_absent" {
|
||||
t.Fatalf("busy aggregate %+v %v", got, err)
|
||||
}
|
||||
bytes, _ := json.Marshal(got)
|
||||
var fields map[string]any
|
||||
json.Unmarshal(bytes, &fields)
|
||||
for key := range fields {
|
||||
if key != "available" && key != "reason" && key != "timezone" && key != "nextOpeningAt" {
|
||||
t.Fatalf("private status field %s", key)
|
||||
}
|
||||
}
|
||||
other := &models.Channel{AIAgentID: ai.ID + 999, Status: enums.StatusOk}
|
||||
got, err = SupportAvailabilityService.PublicStatus(other)
|
||||
if err != nil || got.Available {
|
||||
t.Fatalf("other channel leaked available operator: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportAvailabilityNoEnabledRoutedTeamIsNotOutsideHours(t *testing.T) {
|
||||
f, profile, ai := newAvailabilityFixture(t)
|
||||
if err := f.db.Model(&models.AgentTeam{}).Where("id = ?", profile.TeamID).Update("status", enums.StatusDisabled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk})
|
||||
if err != nil || got.Reason != "temporarily_absent" || got.Available || got.NextOpeningAt != "" {
|
||||
t.Fatalf("disabled team: %+v %v", got, err)
|
||||
}
|
||||
if err := f.db.Model(&ai).Update("team_ids", "9999").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk})
|
||||
if err != nil || got.Reason != "temporarily_absent" || got.Available || got.NextOpeningAt != "" {
|
||||
t.Fatalf("missing team: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportAvailabilityWSLeaseDoesNotRenewAfterCloseOrFromVisitorSocket(t *testing.T) {
|
||||
f, profile, _ := newAvailabilityFixture(t)
|
||||
if err := f.db.Where("user_id = ?", profile.UserID).Delete(&models.AgentPresenceLease{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ws := newWsService()
|
||||
session := &ClientSession{Principal: f.principal, Role: realtimeRoleNotification, PresenceLeaseID: "platform-connection"}
|
||||
ws.renewAgentPresence(session)
|
||||
var lease models.AgentPresenceLease
|
||||
if err := f.db.First(&lease, "id = ?", session.PresenceLeaseID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lease.ExpiresAt.After(time.Now().Add(agentPresenceLeaseTTL)) || !lease.ExpiresAt.After(time.Now().Add(agentPresenceDisconnectGrace)) {
|
||||
t.Fatalf("unexpected lease expiry: %v", lease.ExpiresAt)
|
||||
}
|
||||
ws.endAgentPresence(session)
|
||||
session.PresenceRenewedAt.Store(0)
|
||||
ws.renewAgentPresence(session)
|
||||
if err := f.db.First(&lease, "id = ?", session.PresenceLeaseID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lease.ExpiresAt.After(time.Now().Add(agentPresenceDisconnectGrace)) {
|
||||
t.Fatal("closed session was renewed")
|
||||
}
|
||||
visitor := &ClientSession{Principal: f.principal, Role: realtimeRoleUser, PresenceLeaseID: "visitor-connection"}
|
||||
ws.renewAgentPresence(visitor)
|
||||
var count int64
|
||||
if err := f.db.Model(&models.AgentPresenceLease{}).Where("id = ?", visitor.PresenceLeaseID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("visitor socket established platform presence")
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/repositories"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
const supportNextOpeningSearchDays = 366
|
||||
|
||||
var SupportCalendarService = &supportCalendarService{}
|
||||
|
||||
type supportCalendarService struct{}
|
||||
type supportCalendarEvaluation struct {
|
||||
Open bool
|
||||
Timezone, Mode string
|
||||
NextOpeningAt *time.Time
|
||||
}
|
||||
type supportCalendarInterval struct{ start, end time.Time }
|
||||
|
||||
func supportAvailabilityPersistenceError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errorsx.InvalidParamI18n("error.supportAvailability.persistence")
|
||||
}
|
||||
func (s *supportCalendarService) Get(teamID int64) (*response.SupportCalendarResponse, error) {
|
||||
return s.get(sqls.DB(), teamID)
|
||||
}
|
||||
func (s *supportCalendarService) get(db *gorm.DB, teamID int64) (*response.SupportCalendarResponse, error) {
|
||||
global, err := repositories.SupportAvailabilityRepository.Calendar(db, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zone := "UTC"
|
||||
if global != nil && global.Timezone != "" {
|
||||
zone = global.Timezone
|
||||
}
|
||||
item := global
|
||||
if teamID != 0 {
|
||||
item, err = repositories.SupportAvailabilityRepository.Calendar(db, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ret := &response.SupportCalendarResponse{SupportCalendarRequest: request.SupportCalendarRequest{TeamID: teamID, Mode: "legacy", Weekly: []request.SupportCalendarWeeklyPeriod{}, Exceptions: []request.SupportCalendarException{}}, EffectiveTimezone: zone, DeploymentTimezone: zone}
|
||||
if teamID == 0 {
|
||||
ret.Timezone = zone
|
||||
ret.Mode = "recurring"
|
||||
}
|
||||
if item != nil {
|
||||
ret.Mode = item.Mode
|
||||
ret.Timezone = item.Timezone
|
||||
if err = json.Unmarshal([]byte(item.WeeklyJSON), &ret.Weekly); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal([]byte(item.ExceptionsJSON), &ret.Exceptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.Timezone != "" {
|
||||
ret.EffectiveTimezone = item.Timezone
|
||||
}
|
||||
}
|
||||
// Dated schedules are absolute stored instants; their existing input/display
|
||||
// convention is the process local zone, never the new recurring UTC default.
|
||||
if ret.Mode == "legacy" {
|
||||
ret.EffectiveTimezone = time.Local.String()
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
func validateSupportCalendar(req request.SupportCalendarRequest) error {
|
||||
invalid := func() error { return errorsx.InvalidParamI18n("error.supportAvailability.invalidCalendar") }
|
||||
if req.TeamID < 0 || (req.Mode != "legacy" && req.Mode != "recurring") || len(req.Weekly) > 100 || len(req.Exceptions) > 730 {
|
||||
return invalid()
|
||||
}
|
||||
if req.TeamID == 0 && (req.Timezone == "" || len(req.Weekly) > 0 || len(req.Exceptions) > 0) {
|
||||
return invalid()
|
||||
}
|
||||
if req.Timezone != "" {
|
||||
if req.Timezone == "Local" {
|
||||
return invalid()
|
||||
}
|
||||
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
||||
return invalid()
|
||||
}
|
||||
}
|
||||
for _, period := range req.Weekly {
|
||||
if period.Weekday < 1 || period.Weekday > 7 || !validSupportPeriod(period.SupportCalendarPeriod) {
|
||||
return invalid()
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, exception := range req.Exceptions {
|
||||
date, err := time.Parse(time.DateOnly, exception.Date)
|
||||
if err != nil || date.Format(time.DateOnly) != exception.Date || seen[exception.Date] || len(exception.Periods) > 24 || exception.Closed && len(exception.Periods) > 0 {
|
||||
return invalid()
|
||||
}
|
||||
seen[exception.Date] = true
|
||||
for _, period := range exception.Periods {
|
||||
if !validSupportPeriod(period) {
|
||||
return invalid()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func supportClockMinutes(clock string, allow24 bool) (int, bool) {
|
||||
if allow24 && clock == "24:00" {
|
||||
return 1440, true
|
||||
}
|
||||
t, err := time.Parse("15:04", clock)
|
||||
if err != nil || t.Format("15:04") != clock {
|
||||
return 0, false
|
||||
}
|
||||
return t.Hour()*60 + t.Minute(), true
|
||||
}
|
||||
func validSupportPeriod(period request.SupportCalendarPeriod) bool {
|
||||
a, ok := supportClockMinutes(period.Start, false)
|
||||
b, ok2 := supportClockMinutes(period.End, true)
|
||||
return ok && ok2 && a != b
|
||||
}
|
||||
func (s *supportCalendarService) Save(req request.SupportCalendarRequest) (*response.SupportCalendarResponse, error) {
|
||||
req.Timezone = strings.TrimSpace(req.Timezone)
|
||||
if err := validateSupportCalendar(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.TeamID > 0 {
|
||||
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), req.TeamID)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
if team == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0169")
|
||||
}
|
||||
}
|
||||
if req.TeamID == 0 {
|
||||
req.Mode = "recurring"
|
||||
}
|
||||
if req.Weekly == nil {
|
||||
req.Weekly = []request.SupportCalendarWeeklyPeriod{}
|
||||
}
|
||||
if req.Exceptions == nil {
|
||||
req.Exceptions = []request.SupportCalendarException{}
|
||||
}
|
||||
weekly, _ := json.Marshal(req.Weekly)
|
||||
exceptions, _ := json.Marshal(req.Exceptions)
|
||||
item := &models.SupportCalendar{TeamID: req.TeamID, Mode: req.Mode, Timezone: req.Timezone, WeeklyJSON: string(weekly), ExceptionsJSON: string(exceptions), UpdatedAt: time.Now()}
|
||||
if err := repositories.SupportAvailabilityRepository.SaveCalendar(sqls.DB(), item); err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
ret, err := s.Get(req.TeamID)
|
||||
return ret, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
func (s *supportCalendarService) Preview(req request.SupportCalendarPreviewRequest) (*response.SupportCalendarPreviewResponse, error) {
|
||||
if err := validateSupportCalendar(req.Calendar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
if req.At != "" {
|
||||
var err error
|
||||
now, err = time.Parse(time.RFC3339, req.At)
|
||||
if err != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidTime")
|
||||
}
|
||||
}
|
||||
calendar := &response.SupportCalendarResponse{SupportCalendarRequest: req.Calendar, EffectiveTimezone: req.Calendar.Timezone}
|
||||
if calendar.EffectiveTimezone == "" {
|
||||
global, err := s.get(sqls.DB(), 0)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
calendar.EffectiveTimezone = global.EffectiveTimezone
|
||||
}
|
||||
result, err := s.evaluateCalendar(sqls.DB(), calendar, now)
|
||||
if err != nil {
|
||||
return nil, supportAvailabilityPersistenceError(err)
|
||||
}
|
||||
return &response.SupportCalendarPreviewResponse{Open: result.Open, Timezone: result.Timezone, Mode: result.Mode, NextOpeningAt: supportTimeString(result.NextOpeningAt)}, nil
|
||||
}
|
||||
func (s *supportCalendarService) Evaluate(db *gorm.DB, teamID int64, now time.Time) (supportCalendarEvaluation, error) {
|
||||
calendar, err := s.get(db, teamID)
|
||||
if err != nil {
|
||||
return supportCalendarEvaluation{}, err
|
||||
}
|
||||
return s.evaluateCalendar(db, calendar, now)
|
||||
}
|
||||
func (s *supportCalendarService) evaluateCalendar(db *gorm.DB, calendar *response.SupportCalendarResponse, now time.Time) (supportCalendarEvaluation, error) {
|
||||
if calendar.Mode == "legacy" {
|
||||
ret := supportCalendarEvaluation{Mode: "legacy", Timezone: time.Local.String()}
|
||||
items, err := repositories.SupportAvailabilityRepository.LegacySchedules(db, calendar.TeamID, now)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.StartAt.After(now) {
|
||||
ret.Open = true
|
||||
} else if ret.NextOpeningAt == nil {
|
||||
next := item.StartAt
|
||||
ret.NextOpeningAt = &next
|
||||
}
|
||||
}
|
||||
if ret.Open {
|
||||
ret.NextOpeningAt = nil
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
return evaluateRecurringSupportCalendar(calendar, now)
|
||||
}
|
||||
func evaluateRecurringSupportCalendar(calendar *response.SupportCalendarResponse, now time.Time) (supportCalendarEvaluation, error) {
|
||||
ret := supportCalendarEvaluation{Mode: "recurring", Timezone: calendar.EffectiveTimezone}
|
||||
loc, err := time.LoadLocation(calendar.EffectiveTimezone)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
day := now.In(loc)
|
||||
year, month, date := day.Date()
|
||||
day = time.Date(year, month, date, 12, 0, 0, 0, loc)
|
||||
exceptions := map[string]request.SupportCalendarException{}
|
||||
for _, item := range calendar.Exceptions {
|
||||
exceptions[item.Date] = item
|
||||
}
|
||||
for offset := -1; offset <= supportNextOpeningSearchDays; offset++ {
|
||||
current := day.AddDate(0, 0, offset)
|
||||
periods := []request.SupportCalendarPeriod{}
|
||||
if exception, ok := exceptions[current.Format(time.DateOnly)]; ok {
|
||||
if !exception.Closed {
|
||||
periods = exception.Periods
|
||||
}
|
||||
} else {
|
||||
for _, p := range calendar.Weekly {
|
||||
if p.Weekday == weekdayForBatchRequest(current) {
|
||||
periods = append(periods, p.SupportCalendarPeriod)
|
||||
}
|
||||
}
|
||||
}
|
||||
intervals := []supportCalendarInterval{}
|
||||
for _, period := range periods {
|
||||
startMinute, ok := supportClockMinutes(period.Start, false)
|
||||
endMinute, ok2 := supportClockMinutes(period.End, true)
|
||||
if !ok || !ok2 || startMinute == endMinute {
|
||||
return ret, fmt.Errorf("invalid calendar period")
|
||||
}
|
||||
endDay := current
|
||||
if endMinute <= startMinute || endMinute == 1440 {
|
||||
endDay = current.AddDate(0, 0, 1)
|
||||
if endMinute == 1440 {
|
||||
endMinute = 0
|
||||
}
|
||||
}
|
||||
start, err := resolveSupportWallTime(current, startMinute, loc, false)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
end, err := resolveSupportWallTime(endDay, endMinute, loc, true)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
// Any exception owns its whole civil date, including an overnight carry.
|
||||
if endDay.Format(time.DateOnly) != current.Format(time.DateOnly) {
|
||||
if _, exists := exceptions[endDay.Format(time.DateOnly)]; exists {
|
||||
end, err = resolveSupportWallTime(endDay, 0, loc, false)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if end.After(start) {
|
||||
intervals = append(intervals, supportCalendarInterval{start: start, end: end})
|
||||
}
|
||||
}
|
||||
sort.Slice(intervals, func(i, j int) bool { return intervals[i].start.Before(intervals[j].start) })
|
||||
for _, interval := range intervals {
|
||||
if !now.Before(interval.start) && now.Before(interval.end) {
|
||||
ret.Open = true
|
||||
ret.NextOpeningAt = nil
|
||||
return ret, nil
|
||||
}
|
||||
if interval.start.After(now) && (ret.NextOpeningAt == nil || interval.start.Before(*ret.NextOpeningAt)) {
|
||||
next := interval.start
|
||||
ret.NextOpeningAt = &next
|
||||
}
|
||||
}
|
||||
if ret.NextOpeningAt != nil && offset >= 0 {
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// At a DST fold the earlier occurrence starts a shift and the later occurrence
|
||||
// ends it. Missing boundary minutes move forward to the first valid wall minute.
|
||||
// This is deterministic across hosts and includes both occurrences of a fold.
|
||||
func resolveSupportWallTime(day time.Time, minute int, loc *time.Location, end bool) (time.Time, error) {
|
||||
y, m, d := day.Date()
|
||||
wall := time.Date(y, m, d, minute/60, minute%60, 0, 0, time.UTC)
|
||||
offsets := map[int]bool{}
|
||||
for delta := -48; delta <= 48; delta += 6 {
|
||||
_, offset := day.Add(time.Duration(delta) * time.Hour).Zone()
|
||||
offsets[offset] = true
|
||||
}
|
||||
for shift := 0; shift <= 24*60; shift++ {
|
||||
target := wall.Add(time.Duration(shift) * time.Minute)
|
||||
var candidates []time.Time
|
||||
for offset := range offsets {
|
||||
instant := target.Add(-time.Duration(offset) * time.Second)
|
||||
local := instant.In(loc)
|
||||
if local.Format("2006-01-02 15:04") == target.Format("2006-01-02 15:04") {
|
||||
candidates = append(candidates, instant)
|
||||
}
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Before(candidates[j]) })
|
||||
if end {
|
||||
return candidates[len(candidates)-1], nil
|
||||
}
|
||||
return candidates[0], nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unresolvable civil time")
|
||||
}
|
||||
func supportTimeString(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
)
|
||||
|
||||
// Service integration fixtures may enqueue outbound work asynchronously. Keep
|
||||
// those clients inside this test process's loopback fixtures even when a test
|
||||
// accidentally supplies an external URL. Production transports are unchanged.
|
||||
type loopbackTestTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t loopbackTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
host := req.URL.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||
return nil, fmt.Errorf("external HTTP transport disabled in service tests: %s", host)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// These service fixtures do not contain AI-runtime tables/configuration.
|
||||
// Runtime behavior has its own suite; focused hook tests install their own
|
||||
// spies. Never let a transitive runtime init launch unowned background work.
|
||||
TriggerAIReplyAsyncHook = func(models.Conversation, models.Message) {}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
http.DefaultTransport = loopbackTestTransport{base: transport}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type testRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestServiceTransportRejectsExternalHostsBeforeDial(t *testing.T) {
|
||||
for _, address := range []string{"https://slack.com/api/chat.postMessage", "https://api.telegram.org/test", "http://example.invalid", "http://192.0.2.1", "http://[2001:db8::1]"} {
|
||||
t.Run(address, func(t *testing.T) {
|
||||
transport := loopbackTestTransport{base: testRoundTripper(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("external request reached the underlying transport")
|
||||
return nil, nil
|
||||
})}
|
||||
req, err := http.NewRequest(http.MethodGet, address, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("external request was not blocked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTransportRejectsRedirectOutsideLoopback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "https://example.invalid/forbidden", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
response, err := http.Get(server.URL)
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "external HTTP transport disabled") {
|
||||
t.Fatalf("redirect did not hit the loopback guard: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -110,31 +110,15 @@ func TestWsStaffRevocationClosesBeforeDeliveryOrIncomingMessage(t *testing.T) {
|
||||
name string
|
||||
change func(*authSessionFixture) error
|
||||
}{
|
||||
{"revoked login", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.session).Update("revoked_at", time.Now()).Error
|
||||
}},
|
||||
{"expired login", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.session).Update("expired_at", time.Now().Add(-time.Second)).Error
|
||||
}},
|
||||
{"disabled user", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error
|
||||
}},
|
||||
{"downgraded user type", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.user).Update("user_type", enums.UserTypeUser).Error
|
||||
}},
|
||||
{"revoked login", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", time.Now()).Error }},
|
||||
{"expired login", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", time.Now().Add(-time.Second)).Error }},
|
||||
{"disabled user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error }},
|
||||
{"downgraded user type", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("user_type", enums.UserTypeUser).Error }},
|
||||
{"deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", time.Now()).Error }},
|
||||
{"removed role", func(f *authSessionFixture) error {
|
||||
return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error
|
||||
}},
|
||||
{"disabled role", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error
|
||||
}},
|
||||
{"removed permission", func(f *authSessionFixture) error {
|
||||
return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error
|
||||
}},
|
||||
{"disabled permission", func(f *authSessionFixture) error {
|
||||
return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error
|
||||
}},
|
||||
{"removed role", func(f *authSessionFixture) error { return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error }},
|
||||
{"disabled role", func(f *authSessionFixture) error { return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error }},
|
||||
{"removed permission", func(f *authSessionFixture) error { return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error }},
|
||||
{"disabled permission", func(f *authSessionFixture) error { return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
for _, trigger := range []string{"fanout", "incoming"} {
|
||||
|
||||
@@ -25,9 +25,7 @@ func (m *WsConnectionManager) Register(session *ClientSession, defaultTopics []s
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if session.Closed.Load() {
|
||||
return len(m.sessions)
|
||||
}
|
||||
if session.Closed.Load() { return len(m.sessions) }
|
||||
m.sessions[session.ID] = session
|
||||
for _, topic := range defaultTopics {
|
||||
m.subscribeLocked(session, topic)
|
||||
@@ -58,9 +56,7 @@ func (m *WsConnectionManager) Subscribe(session *ClientSession, topics []string)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if session.Closed.Load() {
|
||||
return nil
|
||||
}
|
||||
if session.Closed.Load() { return nil }
|
||||
ret := make([]string, 0, len(topics))
|
||||
for _, topic := range topics {
|
||||
if _, exists := session.Topics[topic]; exists {
|
||||
@@ -144,12 +140,8 @@ func (m *WsConnectionManager) unsubscribeLocked(session *ClientSession, topic st
|
||||
func (m *WsConnectionManager) TopicList(session *ClientSession) []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
if session == nil { return nil }
|
||||
topics := make([]string, 0, len(session.Topics))
|
||||
for topic := range session.Topics {
|
||||
topics = append(topics, topic)
|
||||
}
|
||||
for topic := range session.Topics { topics = append(topics, topic) }
|
||||
return topics
|
||||
}
|
||||
|
||||
@@ -13,127 +13,92 @@ import (
|
||||
|
||||
// The listener exists only inside the test. No app server or external service is
|
||||
// involved; the DB fixture is temporary and every pump is joined before cleanup.
|
||||
func newSecuritySocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn) {
|
||||
func newSecuritySocketPair(t *testing.T) (*websocket.Conn,*websocket.Conn) {
|
||||
t.Helper()
|
||||
accepted := make(chan *websocket.Conn, 1)
|
||||
failed := make(chan error, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upgrader := websocket.Upgrader{}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
failed <- err
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
accepted:=make(chan *websocket.Conn,1)
|
||||
failed:=make(chan error,1)
|
||||
server:=httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){
|
||||
upgrader:=websocket.Upgrader{}
|
||||
conn,err:=upgrader.Upgrade(w,r,nil)
|
||||
if err!=nil{failed<-err;return}
|
||||
accepted<-conn
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
client, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client,_,err:=websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL,"http"),nil)
|
||||
if err!=nil{t.Fatal(err)}
|
||||
var serverConn *websocket.Conn
|
||||
select {
|
||||
case serverConn = <-accepted:
|
||||
case err := <-failed:
|
||||
t.Fatal(err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("test websocket upgrade timed out")
|
||||
select{
|
||||
case serverConn=<-accepted:
|
||||
case err:=<-failed:t.Fatal(err)
|
||||
case <-time.After(time.Second):t.Fatal("test websocket upgrade timed out")
|
||||
}
|
||||
t.Cleanup(func() { _ = client.Close(); _ = serverConn.Close() })
|
||||
return serverConn, client
|
||||
t.Cleanup(func(){_ = client.Close();_ = serverConn.Close()})
|
||||
return serverConn,client
|
||||
}
|
||||
|
||||
func startSecurityPumps(t *testing.T, ws *wsService, session *ClientSession, reader bool) {
|
||||
func startSecurityPumps(t *testing.T,ws *wsService,session *ClientSession,reader bool) {
|
||||
t.Helper()
|
||||
written := make(chan struct{})
|
||||
go func() { defer close(written); ws.writePump(session) }()
|
||||
written:=make(chan struct{})
|
||||
go func(){defer close(written);ws.writePump(session)}()
|
||||
var read chan struct{}
|
||||
if reader {
|
||||
read = make(chan struct{})
|
||||
go func() { defer close(read); ws.readPump(session) }()
|
||||
if reader{
|
||||
read=make(chan struct{})
|
||||
go func(){defer close(read);ws.readPump(session)}()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
t.Cleanup(func(){
|
||||
ws.closeSession(session)
|
||||
select {
|
||||
case <-written:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("write pump did not stop")
|
||||
}
|
||||
if read != nil {
|
||||
select {
|
||||
case <-read:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("read pump did not stop")
|
||||
}
|
||||
}
|
||||
select{case <-written:case <-time.After(time.Second):t.Error("write pump did not stop")}
|
||||
if read!=nil{select{case <-read:case <-time.After(time.Second):t.Error("read pump did not stop")}}
|
||||
})
|
||||
}
|
||||
|
||||
func requireSecuritySocketClosed(t *testing.T, client *websocket.Conn) {
|
||||
func requireSecuritySocketClosed(t *testing.T,client *websocket.Conn) {
|
||||
t.Helper()
|
||||
_ = client.SetReadDeadline(time.Now().Add(time.Second))
|
||||
_, _, err := client.ReadMessage()
|
||||
if err == nil {
|
||||
t.Fatal("revoked connection delivered a buffered message")
|
||||
}
|
||||
if networkErr, ok := err.(interface{ Timeout() bool }); ok && networkErr.Timeout() {
|
||||
t.Fatal("revoked connection stayed open until test deadline")
|
||||
}
|
||||
_,_,err:=client.ReadMessage()
|
||||
if err==nil{t.Fatal("revoked connection delivered a buffered message")}
|
||||
if networkErr,ok:=err.(interface{Timeout()bool});ok && networkErr.Timeout(){t.Fatal("revoked connection stayed open until test deadline")}
|
||||
}
|
||||
|
||||
func TestWsWritePumpRejectsBufferedEventAfterRevocation(t *testing.T) {
|
||||
f := newWsAuthFixture(t, true)
|
||||
ws := newWsService()
|
||||
session := newWsAuthClient(f, "buffered", realtimeRoleAdmin)
|
||||
serverConn, client := newSecuritySocketPair(t)
|
||||
session.Conn = serverConn
|
||||
ws.manager.Register(session, ws.defaultTopics(session))
|
||||
session.enqueueEvent(ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
|
||||
if err := f.db.Model(&f.session).Update("revoked_at", time.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startSecurityPumps(t, ws, session, false)
|
||||
requireSecuritySocketClosed(t, client)
|
||||
if !session.Closed.Load() {
|
||||
t.Fatal("writer did not revoke session")
|
||||
}
|
||||
f:=newWsAuthFixture(t,true)
|
||||
ws:=newWsService()
|
||||
session:=newWsAuthClient(f,"buffered",realtimeRoleAdmin)
|
||||
serverConn,client:=newSecuritySocketPair(t)
|
||||
session.Conn=serverConn
|
||||
ws.manager.Register(session,ws.defaultTopics(session))
|
||||
session.enqueueEvent(ws.newEvent(realtimeTopicAdminAll,RealtimeConversationChangedEvent{Type:enums.IMRealtimeEventConversationUpdated}))
|
||||
if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)}
|
||||
startSecurityPumps(t,ws,session,false)
|
||||
requireSecuritySocketClosed(t,client)
|
||||
if !session.Closed.Load(){t.Fatal("writer did not revoke session")}
|
||||
}
|
||||
|
||||
func TestWsReadPumpRejectsIncomingFrameAfterRevocation(t *testing.T) {
|
||||
f := newWsAuthFixture(t, true)
|
||||
ws := newWsService()
|
||||
session := newWsAuthClient(f, "incoming", realtimeRoleAdmin)
|
||||
serverConn, client := newSecuritySocketPair(t)
|
||||
session.Conn = serverConn
|
||||
ws.manager.Register(session, ws.defaultTopics(session))
|
||||
startSecurityPumps(t, ws, session, true)
|
||||
if err := f.db.Model(&f.session).Update("revoked_at", time.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.WriteMessage(websocket.TextMessage, []byte(`{"type":"ping"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireSecuritySocketClosed(t, client)
|
||||
if !session.Closed.Load() {
|
||||
t.Fatal("reader did not revoke session")
|
||||
}
|
||||
f:=newWsAuthFixture(t,true)
|
||||
ws:=newWsService()
|
||||
session:=newWsAuthClient(f,"incoming",realtimeRoleAdmin)
|
||||
serverConn,client:=newSecuritySocketPair(t)
|
||||
session.Conn=serverConn
|
||||
ws.manager.Register(session,ws.defaultTopics(session))
|
||||
startSecurityPumps(t,ws,session,true)
|
||||
if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)}
|
||||
if err:=client.WriteMessage(websocket.TextMessage,[]byte(`{"type":"ping"}`));err!=nil{t.Fatal(err)}
|
||||
requireSecuritySocketClosed(t,client)
|
||||
if !session.Closed.Load(){t.Fatal("reader did not revoke session")}
|
||||
}
|
||||
|
||||
func TestWsIdlePumpClosesRevokedConnection(t *testing.T) {
|
||||
f := newWsAuthFixture(t, true)
|
||||
ws := newWsService()
|
||||
f:=newWsAuthFixture(t,true)
|
||||
ws:=newWsService()
|
||||
// Same production timer path, shortened only on this isolated service instance.
|
||||
ws.authCheckPeriod = 10 * time.Millisecond
|
||||
session := newWsAuthClient(f, "idle", realtimeRoleAdmin)
|
||||
serverConn, client := newSecuritySocketPair(t)
|
||||
session.Conn = serverConn
|
||||
ws.manager.Register(session, ws.defaultTopics(session))
|
||||
startSecurityPumps(t, ws, session, false)
|
||||
if err := f.db.Model(&f.session).Update("revoked_at", time.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireSecuritySocketClosed(t, client)
|
||||
if !session.Closed.Load() {
|
||||
t.Fatal("idle authorization timer did not revoke session")
|
||||
}
|
||||
ws.authCheckPeriod=10*time.Millisecond
|
||||
session:=newWsAuthClient(f,"idle",realtimeRoleAdmin)
|
||||
serverConn,client:=newSecuritySocketPair(t)
|
||||
session.Conn=serverConn
|
||||
ws.manager.Register(session,ws.defaultTopics(session))
|
||||
startSecurityPumps(t,ws,session,false)
|
||||
if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)}
|
||||
requireSecuritySocketClosed(t,client)
|
||||
if !session.Closed.Load(){t.Fatal("idle authorization timer did not revoke session")}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package services
|
||||
@@ -314,10 +313,6 @@ type realtimeClientMessage struct {
|
||||
}
|
||||
|
||||
type ClientSession struct {
|
||||
presenceMu sync.Mutex
|
||||
PresenceEnded bool
|
||||
PresenceLeaseID string
|
||||
PresenceRenewedAt atomic.Int64
|
||||
ID string
|
||||
Conn *websocket.Conn
|
||||
Principal *dto.AuthPrincipal
|
||||
@@ -334,14 +329,10 @@ type ClientSession struct {
|
||||
}
|
||||
|
||||
func (s *ClientSession) enqueue(payload []byte) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if s == nil { return false }
|
||||
s.sendMu.RLock()
|
||||
defer s.sendMu.RUnlock()
|
||||
if s.Closed.Load() {
|
||||
return false
|
||||
}
|
||||
if s.Closed.Load() { return false }
|
||||
select {
|
||||
case s.Send <- payload:
|
||||
return true
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package services
|
||||
@@ -129,22 +128,13 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
|
||||
Topics: make(map[string]struct{}),
|
||||
Send: make(chan []byte, realtimeSendBufferSize),
|
||||
}
|
||||
if len(customerSessionInfo) > 0 {
|
||||
session.CustomerSession = customerSessionInfo[0]
|
||||
}
|
||||
if len(customerSessionInfo) > 0 { session.CustomerSession = customerSessionInfo[0] }
|
||||
session.touch()
|
||||
if principal != nil && (role == realtimeRoleAdmin || role == realtimeRoleNotification) {
|
||||
session.PresenceLeaseID, _ = randomToken("presence_")
|
||||
}
|
||||
|
||||
conn.SetReadLimit(realtimeMaxMessageSize)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
if !s.revalidateSession(session) {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
session.touch()
|
||||
s.renewAgentPresence(session)
|
||||
return conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
||||
})
|
||||
|
||||
@@ -186,7 +176,6 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
|
||||
},
|
||||
}))
|
||||
}
|
||||
s.renewAgentPresence(session)
|
||||
go s.writePump(session)
|
||||
go s.readPump(session)
|
||||
return nil
|
||||
@@ -206,9 +195,7 @@ func (s *wsService) readPump(session *ClientSession) {
|
||||
}
|
||||
|
||||
func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) {
|
||||
if !s.revalidateSession(session) {
|
||||
return
|
||||
}
|
||||
if !s.revalidateSession(session) { return }
|
||||
input := realtimeClientMessage{}
|
||||
if err := json.Unmarshal(payload, &input); err != nil {
|
||||
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
|
||||
@@ -221,7 +208,6 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
|
||||
|
||||
switch strings.TrimSpace(input.Type) {
|
||||
case enums.IMRealtimeClientTypePing:
|
||||
s.renewAgentPresence(session)
|
||||
session.enqueueEvent(s.newEvent("", RealtimePongEvent{}))
|
||||
case enums.IMRealtimeClientTypeSubscribe:
|
||||
topics := s.subscribeTopics(session, input.Topics)
|
||||
@@ -278,22 +264,14 @@ func (s *wsService) writePump(session *ClientSession) {
|
||||
}
|
||||
// Recheck after queueing as well: logout or permission revocation can
|
||||
// happen while a slow client still has messages buffered.
|
||||
if !s.revalidateSession(session) {
|
||||
return
|
||||
}
|
||||
var envelope struct {
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
if json.Unmarshal(payload, &envelope) != nil || (envelope.Topic != "" && len(s.filterAllowedTopics(session, []string{envelope.Topic})) == 0) {
|
||||
continue
|
||||
}
|
||||
if !s.revalidateSession(session) { return }
|
||||
var envelope struct { Topic string `json:"topic"` }
|
||||
if json.Unmarshal(payload, &envelope) != nil || (envelope.Topic != "" && len(s.filterAllowedTopics(session, []string{envelope.Topic})) == 0) { continue }
|
||||
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return
|
||||
}
|
||||
case <-authTicker.C:
|
||||
if !s.revalidateSession(session) {
|
||||
return
|
||||
}
|
||||
if !s.revalidateSession(session) { return }
|
||||
case <-ticker.C:
|
||||
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
|
||||
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
@@ -308,12 +286,9 @@ func (s *wsService) closeSession(session *ClientSession) {
|
||||
return
|
||||
}
|
||||
session.closeOnce.Do(func() {
|
||||
s.endAgentPresence(session)
|
||||
session.sendMu.Lock()
|
||||
session.Closed.Store(true)
|
||||
if session.Send != nil {
|
||||
close(session.Send)
|
||||
}
|
||||
if session.Send != nil { close(session.Send) }
|
||||
session.sendMu.Unlock()
|
||||
remaining := s.manager.Unregister(session)
|
||||
|
||||
@@ -638,9 +613,7 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
|
||||
}
|
||||
|
||||
for _, session := range targets {
|
||||
if !s.revalidateSession(session) || !s.canReceiveEvent(session, normalized, event.Topic) {
|
||||
continue
|
||||
}
|
||||
if !s.revalidateSession(session) || !s.canReceiveEvent(session, normalized, event.Topic) { continue }
|
||||
if filter != nil && !filter(session) {
|
||||
continue
|
||||
}
|
||||
@@ -658,9 +631,7 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
|
||||
}
|
||||
|
||||
func (s *wsService) IsCustomerOnline(customerID, channelID int64) bool {
|
||||
if customerID <= 0 || channelID <= 0 {
|
||||
return false
|
||||
}
|
||||
if customerID <= 0 || channelID <= 0 { return false }
|
||||
return s.manager.HasTopic(s.customerTopic(customerID, channelID))
|
||||
}
|
||||
|
||||
@@ -865,18 +836,14 @@ func formatWsTime(t *time.Time) string {
|
||||
|
||||
// Topic membership is an optimization, never an authorization grant.
|
||||
func (s *wsService) canReceiveEvent(session *ClientSession, routingTopics []string, eventTopic string) bool {
|
||||
if eventTopic != "" {
|
||||
return len(s.filterAllowedTopics(session, []string{eventTopic})) > 0
|
||||
}
|
||||
if eventTopic != "" { return len(s.filterAllowedTopics(session, []string{eventTopic})) > 0 }
|
||||
return len(s.filterAllowedTopics(session, routingTopics)) > 0
|
||||
}
|
||||
|
||||
// Principal snapshots are immutable. Closing when their scope changes avoids
|
||||
// races between fanout/read/write goroutines and forces a fresh subscription set.
|
||||
func (s *wsService) revalidateSession(session *ClientSession) bool {
|
||||
if session == nil || session.Closed.Load() {
|
||||
return false
|
||||
}
|
||||
if session == nil || session.Closed.Load() { return false }
|
||||
valid := false
|
||||
if session.Principal != nil {
|
||||
fresh, err := AuthService.RevalidatePrincipal(session.Principal)
|
||||
@@ -884,8 +851,6 @@ func (s *wsService) revalidateSession(session *ClientSession) bool {
|
||||
} else if session.Role == realtimeRoleUser {
|
||||
valid = CustomerSessionService.VerifyConnection(session.CustomerSession)
|
||||
}
|
||||
if !valid {
|
||||
s.closeSession(session)
|
||||
}
|
||||
if !valid { s.closeSession(session) }
|
||||
return valid
|
||||
}
|
||||
|
||||
@@ -8,27 +8,23 @@ import (
|
||||
// Run with -race: close may be triggered independently by revocation, a reader,
|
||||
// a writer, or slow-client eviction while broadcasts still hold a session pointer.
|
||||
func TestWsConcurrentEnqueueCloseAndSubscribe(t *testing.T) {
|
||||
ws := newWsService()
|
||||
session := &ClientSession{ID: "concurrent", Topics: map[string]struct{}{}, Send: make(chan []byte, 64)}
|
||||
ws.manager.Register(session, []string{"notification:1"})
|
||||
ws:=newWsService()
|
||||
session:=&ClientSession{ID:"concurrent",Topics:map[string]struct{}{},Send:make(chan []byte,64)}
|
||||
ws.manager.Register(session,[]string{"notification:1"})
|
||||
var work sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
for i:=0;i<8;i++ {
|
||||
work.Add(1)
|
||||
go func() {
|
||||
defer work.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
for j:=0;j<100;j++ {
|
||||
session.enqueue([]byte("test"))
|
||||
ws.manager.Subscribe(session, []string{"notification:1"})
|
||||
ws.manager.Subscribe(session,[]string{"notification:1"})
|
||||
ws.manager.TopicList(session)
|
||||
}
|
||||
}()
|
||||
}
|
||||
ws.closeSession(session)
|
||||
work.Wait()
|
||||
if !session.Closed.Load() || ws.manager.HasTopic("notification:1") || len(ws.manager.TopicList(session)) != 0 {
|
||||
t.Fatal("closed session was re-registered")
|
||||
}
|
||||
if session.enqueue([]byte("test")) {
|
||||
t.Fatal("closed session accepted a message")
|
||||
}
|
||||
if !session.Closed.Load() || ws.manager.HasTopic("notification:1") || len(ws.manager.TopicList(session))!=0 { t.Fatal("closed session was re-registered") }
|
||||
if session.enqueue([]byte("test")) { t.Fatal("closed session accepted a message") }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SparklesIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
SETUP_READINESS_UPDATED_EVENT,
|
||||
@@ -27,28 +27,26 @@ export function SetupReadinessBanner({ onUpdated }: {
|
||||
const t = useI18n()
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let latestRequest = 0
|
||||
const loadReadiness = () => {
|
||||
const request = ++latestRequest
|
||||
void fetchSetupReadiness().then((data) => {
|
||||
if (active && request === latestRequest) setReadiness(data)
|
||||
}).catch(() => {
|
||||
// Keep this optional background banner quiet on failure.
|
||||
})
|
||||
const loadReadiness = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSetupReadiness()
|
||||
setReadiness(data)
|
||||
} catch {
|
||||
// Ignore in banner if failed
|
||||
}
|
||||
loadReadiness()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadReadiness()
|
||||
const handleUpdated = () => {
|
||||
loadReadiness()
|
||||
void loadReadiness()
|
||||
onUpdated?.()
|
||||
}
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
active = false
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
}
|
||||
}, [onUpdated])
|
||||
}, [loadReadiness, onUpdated])
|
||||
|
||||
if (!readiness) {
|
||||
return null
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Modified in this independent fork for explicit dated/recurring calendar modes; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
@@ -288,7 +286,6 @@ export default function DashboardAgentTeamSchedulesPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4 lg:p-6">
|
||||
<p className="shrink-0 rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">{t("supportAvailability.datedShiftsNotice")} <Link href="/dashboard/support-availability" className="underline underline-offset-2">{t("supportAvailability.title")}</Link></p>
|
||||
<div className="shrink-0 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ButtonGroup>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -47,7 +46,6 @@ import {
|
||||
type CreateAdminAgentTeamPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { availabilityStatusOptions } from "@/lib/support-availability-i18n";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
@@ -67,7 +65,7 @@ const emptyForm: EditForm = {
|
||||
agentCode: "",
|
||||
displayName: "",
|
||||
avatar: "",
|
||||
serviceStatus: String(ServiceStatus.Idle) as "0" | "1" | "2" | "3" | "4",
|
||||
serviceStatus: String(ServiceStatus.Idle) as "0" | "1",
|
||||
maxConcurrentCount: "0",
|
||||
priorityLevel: "0",
|
||||
autoAssignEnabled: true,
|
||||
@@ -81,7 +79,7 @@ type EditForm = {
|
||||
agentCode: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
serviceStatus: "0" | "1" | "2" | "3" | "4";
|
||||
serviceStatus: "0" | "1";
|
||||
maxConcurrentCount: string;
|
||||
priorityLevel: string;
|
||||
autoAssignEnabled: boolean;
|
||||
@@ -96,7 +94,7 @@ function createEditFormSchema(t: TFunction) {
|
||||
agentCode: z.string().trim().min(1, t("agentProfile.agentCodeRequired")),
|
||||
displayName: z.string().trim().min(1, t("agentProfile.displayNameRequired")),
|
||||
avatar: z.string().trim(),
|
||||
serviceStatus: z.enum(["0", "1", "2", "3", "4"], {
|
||||
serviceStatus: z.enum(["0", "1"], {
|
||||
message: t("agentProfile.statusRequired"),
|
||||
}),
|
||||
maxConcurrentCount: z
|
||||
@@ -114,7 +112,10 @@ function createEditFormSchema(t: TFunction) {
|
||||
}
|
||||
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return availabilityStatusOptions(t, false);
|
||||
return [
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
];
|
||||
}
|
||||
|
||||
function buildForm(item: AdminAgentProfile | null): EditForm {
|
||||
@@ -528,7 +529,7 @@ function AgentEditDialogBody({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{!itemId ? <Field data-invalid={!!errors.serviceStatus}>
|
||||
<Field data-invalid={!!errors.serviceStatus}>
|
||||
<FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
@@ -547,7 +548,7 @@ function AgentEditDialogBody({
|
||||
/>
|
||||
<FieldError errors={[errors.serviceStatus]} />
|
||||
</FieldContent>
|
||||
</Field> : <p className="text-sm text-muted-foreground">{t("supportAvailability.statusSeparate")}</p>}
|
||||
</Field>
|
||||
|
||||
<Field className="min-h-32">
|
||||
<FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
|
||||
@@ -624,12 +625,10 @@ function AgentEditDialogBody({
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
disabled
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.offlineDeprecated")}</p>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -6,19 +5,13 @@ import {
|
||||
PanelLeftOpenIcon,
|
||||
UserCogIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { isSameSession } from "@/lib/session-request-guard";
|
||||
import type { AuthSession } from "@/lib/auth";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { AVAILABILITY_UPDATED_EVENT, notifyAvailabilityUpdated, OperatorStatusDialog } from "@/components/support-availability/operator-status";
|
||||
import { fetchAvailabilityAgents, type OperatorAvailability } from "@/lib/api/support-availability";
|
||||
import { availabilityReasonLabel, availabilityStatusLabel, availabilityStatusOptions } from "@/lib/support-availability-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -31,6 +24,7 @@ import {
|
||||
type CreateAdminAgentProfilePayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { EditDialog } from "./_components/edit";
|
||||
import { AgentTeamSidebar } from "./_components/team-sidebar";
|
||||
@@ -40,33 +34,20 @@ type TFunction = (key: string, values?: Record<string, string | number>) => stri
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("agentProfile.allStatuses") },
|
||||
...availabilityStatusOptions(t),
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
];
|
||||
}
|
||||
|
||||
function getStatusLabel(value: number, t: TFunction) {
|
||||
return (
|
||||
getServiceStatusOptions(t).find((item) => item.value === String(value))
|
||||
?.label ?? String(value)
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardAgentsPage() {
|
||||
const t = useI18n();
|
||||
const { session } = useAuth();
|
||||
const accountId = session?.user.id;
|
||||
const canUpdateAvailability = Boolean(session?.permissions.includes("agent.update"));
|
||||
const [availability, setAvailability] = useState<{ accountId?: number; items: OperatorAvailability[] }>({ items: [] });
|
||||
const [statusDialog, setStatusDialog] = useState<{ session: AuthSession; value: OperatorAvailability } | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let pending = false;
|
||||
async function refresh() {
|
||||
if (pending || !accountId) return;
|
||||
pending = true;
|
||||
try { const items = await fetchAvailabilityAgents(); if (active) setAvailability({ accountId, items }); }
|
||||
catch { if (active) setAvailability({ accountId, items: [] }); }
|
||||
finally { pending = false; }
|
||||
}
|
||||
void refresh();
|
||||
const onRefresh = () => { if (document.visibilityState === "visible") void refresh(); };
|
||||
window.addEventListener(AVAILABILITY_UPDATED_EVENT, onRefresh);
|
||||
const timer = window.setInterval(onRefresh, 30000);
|
||||
return () => { active = false; window.clearInterval(timer); window.removeEventListener(AVAILABILITY_UPDATED_EVENT, onRefresh); };
|
||||
}, [accountId]);
|
||||
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
|
||||
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
@@ -149,21 +130,19 @@ export default function DashboardAgentsPage() {
|
||||
{
|
||||
key: "rules",
|
||||
label: t("agentProfile.columnRules"),
|
||||
render: (item) => {
|
||||
const current = availability.accountId === accountId ? availability.items.find(value => value.userId === item.userId) : undefined;
|
||||
return <>
|
||||
<div className="flex flex-wrap gap-1.5"><Badge variant="outline">{current ? availabilityStatusLabel(current.effectiveServiceStatus, t) : t("supportAvailability.status.unknown")}</Badge>
|
||||
{current ? <Badge variant="outline">{t(current.online ? "supportAvailability.online" : "supportAvailability.offline")}</Badge> : null}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{current ? availabilityReasonLabel(current.reason, t) : t("supportAvailability.reason.unknown")}</p>
|
||||
{current && canUpdateAvailability ? <Button type="button" variant="link" size="sm" className="h-auto px-0" onClick={() => { if (session) setStatusDialog({ session, value: current }); }}>{t("supportAvailability.manageStatus")}</Button> : null}
|
||||
render: (item) => (
|
||||
<>
|
||||
<Badge variant="outline">
|
||||
{getStatusLabel(item.serviceStatus, t)}
|
||||
</Badge>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{t("agentProfile.capacityPriority", {
|
||||
capacity: item.maxConcurrentCount,
|
||||
priority: item.priorityLevel,
|
||||
})}
|
||||
</div>
|
||||
</>;
|
||||
},
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dispatch",
|
||||
@@ -175,7 +154,13 @@ export default function DashboardAgentsPage() {
|
||||
? t("agentProfile.autoAssign")
|
||||
: t("agentProfile.noAutoAssign")}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant={item.receiveOfflineMessage ? "secondary" : "outline"}
|
||||
>
|
||||
{item.receiveOfflineMessage
|
||||
? t("agentProfile.offlineReceive")
|
||||
: t("agentProfile.noOfflineReceive")}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -198,12 +183,11 @@ export default function DashboardAgentsPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, availability, accountId, canUpdateAvailability, session],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{statusDialog && isSameSession(statusDialog.session, session) ? <OperatorStatusDialog value={statusDialog.value} admin onClose={() => setStatusDialog(null)} onSaved={() => notifyAvailabilityUpdated()} /> : null}
|
||||
<div
|
||||
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
|
||||
sidebarCollapsed ? "w-0" : "w-80"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
@@ -1376,15 +1375,13 @@ function ChannelFormBody({
|
||||
)
|
||||
}
|
||||
|
||||
// The origin cannot change within a document. An explicit server snapshot keeps
|
||||
// the generated access links hydration-safe without a state-setting effect.
|
||||
const subscribeToOrigin = () => () => {}
|
||||
const readBrowserOrigin = () => window.location.origin
|
||||
const readServerOrigin = () => ""
|
||||
|
||||
function WebAccessGuide({ channelId }: { channelId: string }) {
|
||||
const t = useI18n()
|
||||
const origin = useSyncExternalStore(subscribeToOrigin, readBrowserOrigin, readServerOrigin)
|
||||
const [origin, setOrigin] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(window.location.origin)
|
||||
}, [])
|
||||
|
||||
const accessUrl = useMemo(() => {
|
||||
if (!origin || !channelId) {
|
||||
|
||||
@@ -381,7 +381,7 @@ export function ChatPanel() {
|
||||
setWorkflowRunLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
[],
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -146,7 +145,7 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{answerResult.latencyMs}ms · {answerResult.modelName || t("knowledge.answerFallback")}
|
||||
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-3 whitespace-pre-wrap">
|
||||
@@ -162,12 +161,12 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{getSearchResultLabel(citation, t)}
|
||||
{getSearchResultLabel(citation)}
|
||||
</div>
|
||||
<Badge variant="outline">{citation.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{citation.sectionPath || citation.title || t("knowledge.chunkNumber", { number: citation.chunkNo })}
|
||||
{citation.sectionPath || citation.title || `Chunk #${citation.chunkNo}`}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{citation.snippet}
|
||||
@@ -193,12 +192,12 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{getSearchResultLabel(item, t)}
|
||||
{getSearchResultLabel(item)}
|
||||
</div>
|
||||
<Badge variant="outline">{item.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || t("knowledge.chunkNumber", { number: item.chunkNo })}
|
||||
{item.sectionPath || item.title || `Chunk #${item.chunkNo}`}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{item.content}
|
||||
@@ -219,7 +218,7 @@ function getSearchResultLabel(item: {
|
||||
faqId?: number
|
||||
documentTitle?: string
|
||||
documentId?: number
|
||||
}, t: (key: string, values?: Record<string, string | number>) => string) {
|
||||
}) {
|
||||
if (item.faqQuestion) {
|
||||
return item.faqQuestion
|
||||
}
|
||||
@@ -227,7 +226,7 @@ function getSearchResultLabel(item: {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId && item.faqId > 0) {
|
||||
return t("knowledge.faqNumber", { number: item.faqId })
|
||||
return `FAQ ${item.faqId}`
|
||||
}
|
||||
return t("knowledge.documentNumber", { number: item.documentId ?? 0 })
|
||||
return `Document ${item.documentId ?? 0}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -61,7 +60,6 @@ import {
|
||||
KnowledgeDocumentIndexStatus,
|
||||
Status,
|
||||
} from "@/lib/generated/enums";
|
||||
import { getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { DocumentEditDialog } from "./document-edit";
|
||||
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
|
||||
@@ -141,7 +139,7 @@ function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
{item.indexError}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -639,7 +637,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
|
||||
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
|
||||
: getKnowledgeIndexErrorLabel(item.indexError, t) || getIndexStatusLabel(item.indexStatus, t)}
|
||||
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -57,7 +56,6 @@ import {
|
||||
} from "@/lib/api/admin";
|
||||
import { KnowledgeDocumentIndexStatus } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { FAQEditDialog } from "./faq-edit";
|
||||
import { FAQImportDialog } from "./faq-import-dialog";
|
||||
@@ -129,7 +127,7 @@ function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
{item.indexError}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -43,41 +42,26 @@ export function KnowledgeBulkMoveDialog({
|
||||
onSubmit,
|
||||
}: KnowledgeBulkMoveDialogProps) {
|
||||
const t = useI18n();
|
||||
const [directoryResult, setDirectoryResult] = useState<{
|
||||
knowledgeBaseId: number;
|
||||
directories: KnowledgeDirectory[];
|
||||
}>({ knowledgeBaseId, directories: [] });
|
||||
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||
const [targetDirectoryId, setTargetDirectoryId] = useState("0");
|
||||
const [selectionScope, setSelectionScope] = useState({ knowledgeBaseId, open });
|
||||
|
||||
// A new base or dialog opening is a new selection session. Reset before the
|
||||
// children render, rather than exposing the previous target for one frame.
|
||||
if (selectionScope.knowledgeBaseId !== knowledgeBaseId || selectionScope.open !== open) {
|
||||
setSelectionScope({ knowledgeBaseId, open });
|
||||
setTargetDirectoryId("0");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setTargetDirectoryId("0");
|
||||
fetchKnowledgeDirectories(knowledgeBaseId)
|
||||
.then((result) => {
|
||||
if (active) setDirectoryResult({ knowledgeBaseId, directories: result });
|
||||
})
|
||||
.then(setDirectories)
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
console.error(error);
|
||||
setDirectoryResult({ knowledgeBaseId, directories: [] });
|
||||
setDirectories([]);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [knowledgeBaseId, open]);
|
||||
|
||||
const directoryOptions = useMemo(() => [
|
||||
{ value: "0", label: t("knowledge.rootContent") },
|
||||
...flattenDirectoryOptions(directoryResult.knowledgeBaseId === knowledgeBaseId ? directoryResult.directories : []),
|
||||
], [directoryResult, knowledgeBaseId, t]);
|
||||
...flattenDirectoryOptions(directories),
|
||||
], [directories, t]);
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
import { markdownToHtml } from "@/components/content-editor/convert";
|
||||
import { KnowledgeDocumentContentType } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { getKnowledgeStatusLabel, getKnowledgeIndexStatusLabel, getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
type KnowledgeContentDetailDialogProps = {
|
||||
@@ -130,11 +128,11 @@ export function KnowledgeContentDetailDialog({
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.status")}</div>
|
||||
<Badge variant="outline">{getKnowledgeStatusLabel(item.status, t)}</Badge>
|
||||
<Badge variant="outline">{item.statusName || item.status}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.indexStatus")}</div>
|
||||
<Badge variant="secondary">{getKnowledgeIndexStatusLabel(item.indexStatus, t)}</Badge>
|
||||
<Badge variant="secondary">{item.indexStatusName || item.indexStatus}</Badge>
|
||||
</div>
|
||||
<DetailField
|
||||
label={t("knowledge.createdAt")}
|
||||
@@ -160,7 +158,7 @@ export function KnowledgeContentDetailDialog({
|
||||
|
||||
{item.indexError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
{item.indexError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
@@ -58,7 +57,7 @@ function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction
|
||||
<div key={item.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
|
||||
<Badge variant="outline">{t("knowledge.chunkNumber", { number: item.chunkNo })}</Badge>
|
||||
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
|
||||
@@ -125,7 +124,6 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 pb-6">
|
||||
<p className="text-xs text-muted-foreground">{t("knowledge.historyRecordedNotice")}</p>
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
|
||||
@@ -145,7 +143,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.requestId")}</div>
|
||||
<div className="text-xs text-muted-foreground">Request ID</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
@@ -167,15 +165,15 @@ export function RetrieveLogDetailDrawer({
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.retrieveStrategy")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
|
||||
<Metric
|
||||
label={t("knowledge.chunkProvider")}
|
||||
label="Chunk Provider"
|
||||
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
|
||||
mono
|
||||
/>
|
||||
<Metric label={t("knowledge.targetToken")} value={detail.log.chunkTargetTokens} />
|
||||
<Metric label={t("knowledge.maxToken")} value={detail.log.chunkMaxTokens} />
|
||||
<Metric label={t("knowledge.overlapToken")} value={detail.log.chunkOverlapTokens} />
|
||||
<Metric label={t("knowledge.rerank")} value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||
<Metric label={t("knowledge.rerankLimitLabel")} value={detail.log.rerankLimit} />
|
||||
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
|
||||
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
|
||||
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
|
||||
<Metric label="Rerank" value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -189,12 +187,12 @@ export function RetrieveLogDetailDrawer({
|
||||
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
|
||||
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
|
||||
<Metric label={t("knowledge.contextChunks")} value={detail.log.usedChunkCount} />
|
||||
<Metric label={t("knowledge.topScore")} value={detail.log.topScore.toFixed(4)} mono />
|
||||
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
|
||||
<Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
|
||||
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
|
||||
<Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
|
||||
<Metric label={t("knowledge.promptTokens")} value={detail.log.promptTokens} />
|
||||
<Metric label={t("knowledge.completionTokens")} value={detail.log.completionTokens} />
|
||||
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
|
||||
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
|
||||
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
|
||||
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
|
||||
</div>
|
||||
@@ -223,10 +221,10 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("knowledge.section", { section: item.sectionPath || item.title || "-" })}</span>
|
||||
<span>{t("knowledge.chunkNumber", { number: item.chunkNo })}</span>
|
||||
<span>{t("knowledge.providerValue", { value: item.provider ? getKnowledgeChunkProviderLabel(item.provider, t) : "-" })}</span>
|
||||
<span>{t("knowledge.scoreValue", { value: item.score.toFixed(4) })}</span>
|
||||
<span>{t("knowledge.rerankValue", { value: item.rerankScore ? item.rerankScore.toFixed(4) : "-" })}</span>
|
||||
<span>Chunk #{item.chunkNo}</span>
|
||||
<span>Provider:{item.provider || "-"}</span>
|
||||
<span>Score:{item.score.toFixed(4)}</span>
|
||||
<span>Rerank:{item.rerankScore ? item.rerankScore.toFixed(4) : "-"}</span>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="text-sm leading-6 whitespace-pre-wrap text-foreground/90">
|
||||
@@ -238,7 +236,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.traceData")}</h3>
|
||||
<h3 className="text-sm font-semibold">TraceData</h3>
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<pre className="overflow-x-auto text-xs leading-6 text-muted-foreground">
|
||||
{traceData ? JSON.stringify(traceData, null, 2) : detail.log.traceData || "-"}
|
||||
@@ -261,9 +259,9 @@ function getHitSourceLabel(item: KnowledgeRetrieveHit, t: TFunction) {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId > 0) {
|
||||
return t("knowledge.faqNumber", { number: item.faqId })
|
||||
return `FAQ #${item.faqId}`
|
||||
}
|
||||
return t("knowledge.documentNumber", { number: item.documentId })
|
||||
return `${t("knowledge.document")} #${item.documentId}`
|
||||
}
|
||||
|
||||
function Metric({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
@@ -191,13 +190,13 @@ export function RetrieveLogList({
|
||||
},
|
||||
{
|
||||
name: "rerankEnabled",
|
||||
label: t("knowledge.rerank"),
|
||||
label: "Rerank",
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: rerankOptions,
|
||||
placeholder: t("knowledge.rerank"),
|
||||
placeholder: "Rerank",
|
||||
},
|
||||
]}
|
||||
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
|
||||
@@ -244,20 +243,20 @@ export function RetrieveLogList({
|
||||
},
|
||||
{
|
||||
key: "topScore",
|
||||
label: t("knowledge.topScore"),
|
||||
label: "TopScore",
|
||||
className: "w-24 text-right font-mono text-xs",
|
||||
render: (item) => item.topScore.toFixed(4),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
label: t("knowledge.chunkProvider"),
|
||||
label: "Provider",
|
||||
className: "w-28",
|
||||
render: (item) =>
|
||||
item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
|
||||
},
|
||||
{
|
||||
key: "rerank",
|
||||
label: t("knowledge.rerank"),
|
||||
label: "Rerank",
|
||||
className: "w-24",
|
||||
render: (item) =>
|
||||
item.rerankEnabled
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { CircleHelpIcon, FileTextIcon, RefreshCwIcon } from "lucide-react"
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
} from "@/lib/api/admin"
|
||||
import { KnowledgeBaseType, Status } from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { getKnowledgeTypeLabel, getKnowledgeStatusLabel } from "@/lib/knowledge-i18n"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
export default function DashboardKnowledgePage() {
|
||||
@@ -85,7 +83,7 @@ export default function DashboardKnowledgePage() {
|
||||
{
|
||||
key: "type",
|
||||
label: t("knowledge.columnType"),
|
||||
render: (item) => <Badge variant="outline">{getKnowledgeTypeLabel(item.knowledgeType, t)}</Badge>,
|
||||
render: (item) => <Badge variant="outline">{item.knowledgeTypeName}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "contentCount",
|
||||
@@ -103,7 +101,7 @@ export default function DashboardKnowledgePage() {
|
||||
label: t("knowledge.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||
{getKnowledgeStatusLabel(item.status, t)}
|
||||
{item.statusName}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -133,7 +131,7 @@ export default function DashboardKnowledgePage() {
|
||||
rowActions={[
|
||||
{
|
||||
key: "rebuild-index",
|
||||
label: () => t("knowledge.rebuildIndex"),
|
||||
label: (item) => t("knowledge.rebuildIndex"),
|
||||
icon: <RefreshCwIcon />,
|
||||
run: async ({ item }) => {
|
||||
await rebuildKnowledgeBaseIndex(item.id)
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { availabilityStatusLabel } from "@/lib/support-availability-i18n"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
@@ -170,11 +169,6 @@ export function SetupWizardView() {
|
||||
}
|
||||
}, [runSimulation, t])
|
||||
|
||||
const invalidateRequests = useCallback(() => {
|
||||
simulationRequestRef.current++
|
||||
readinessRequestRef.current++
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll()
|
||||
const handleUpdated = () => {
|
||||
@@ -183,9 +177,10 @@ export function SetupWizardView() {
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
invalidateRequests()
|
||||
simulationRequestRef.current++
|
||||
readinessRequestRef.current++
|
||||
}
|
||||
}, [invalidateRequests, loadAll])
|
||||
}, [loadAll])
|
||||
|
||||
async function handleQuickFix(action: SetupQuickFixAction, entityId?: number) {
|
||||
if (fixingRef.current) return
|
||||
@@ -761,7 +756,9 @@ export function SetupWizardView() {
|
||||
variant="outline"
|
||||
className="rounded-md text-xs"
|
||||
>
|
||||
{availabilityStatusLabel(cand.service_status, t)}
|
||||
{cand.service_status === 0
|
||||
? t("setupWizard.simulator.statusIdle")
|
||||
: t("setupWizard.simulator.statusBusy")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { notifyAvailabilityUpdated } from "@/components/support-availability/operator-status"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { previewSupportCalendar, updateSupportCalendar, type CalendarPreview, type SupportCalendar, type SupportPeriod } from "@/lib/api/support-availability"
|
||||
import { calendarValidationKey, formatAvailabilityTime, localDateTimeCandidates, selectedLocalDateTimeISO } from "@/lib/support-availability"
|
||||
|
||||
export function CalendarEditor({ initial, writable, onDirtyChange, onBusyChange, onSaved }: { initial: SupportCalendar; writable: boolean; onDirtyChange: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void; onSaved: (value: SupportCalendar) => void }) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const confirm = useConfirm()
|
||||
const { session } = useAuth()
|
||||
const capturedSession = useRef(session)
|
||||
const leaving = useRef(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [preview, setPreview] = useState<CalendarPreview | null>(null)
|
||||
const [previewAt, setPreviewAt] = useState("")
|
||||
const [previewOccurrence, setPreviewOccurrence] = useState("")
|
||||
const [previewError, setPreviewError] = useState("")
|
||||
const mutation = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const previewRequest = useRef(0)
|
||||
const form = useForm<SupportCalendar>({ defaultValues: initial })
|
||||
const { control, register, watch, handleSubmit, reset, setError, clearErrors, formState: { isDirty, errors } } = form
|
||||
const weekly = useFieldArray({ control, name: "weekly" })
|
||||
const exceptions = useFieldArray({ control, name: "exceptions" })
|
||||
const draft = watch()
|
||||
const fingerprint = JSON.stringify(draft)
|
||||
const previewFingerprint = useRef("")
|
||||
const effectiveTimezone = draft.mode === "legacy" ? initial.effectiveTimezone : draft.timezone || initial.deploymentTimezone
|
||||
const locked = !editing || saving
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const weekdays = [1, 2, 3, 4, 5, 6, 7].map(day => ({ value: String(day), label: t(`supportAvailability.weekday.${day}`) }))
|
||||
useEffect(() => { onDirtyChange(isDirty) }, [isDirty, onDirtyChange])
|
||||
useEffect(() => { onBusyChange?.(saving); return () => onBusyChange?.(false) }, [saving, onBusyChange])
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (isDirty && !leaving.current) { event.preventDefault(); event.returnValue = "" } }
|
||||
const guardLink = (event: MouseEvent) => {
|
||||
if (!isDirty || event.defaultPrevented || !(event.target instanceof Element)) return
|
||||
const anchor = event.target.closest("a[href]") as HTMLAnchorElement | null
|
||||
if (!anchor || anchor.target === "_blank" || anchor.origin !== window.location.origin || anchor.href === window.location.href) return
|
||||
event.preventDefault()
|
||||
void confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }).then(approved => { if (approved && mounted.current) { leaving.current = true; reset(initial); window.location.assign(anchor.href) } })
|
||||
}
|
||||
const sourceUrl = window.location.href
|
||||
const sourceState = window.history.state
|
||||
const guardBack = (event: PopStateEvent) => {
|
||||
if (!isDirty || leaving.current || window.location.href === sourceUrl) return
|
||||
const destination = window.location.href
|
||||
// Keep the current form mounted while the user reviews browser Back/Forward.
|
||||
// Capture runs before App Router's normal popstate listener.
|
||||
event.stopImmediatePropagation()
|
||||
window.history.pushState(sourceState, "", sourceUrl)
|
||||
void confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }).then(approved => {
|
||||
if (approved && mounted.current) { leaving.current = true; window.location.assign(destination) }
|
||||
})
|
||||
}
|
||||
window.addEventListener("popstate", guardBack, true)
|
||||
window.addEventListener("beforeunload", warn)
|
||||
document.addEventListener("click", guardLink, true)
|
||||
return () => { window.removeEventListener("popstate", guardBack, true); window.removeEventListener("beforeunload", warn); document.removeEventListener("click", guardLink, true) }
|
||||
}, [isDirty, confirm, initial, reset, t])
|
||||
const invalidatePreview = useCallback(() => { previewRequest.current++ }, [])
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; invalidatePreview() } }, [invalidatePreview])
|
||||
async function cancel() {
|
||||
if (isDirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
reset(initial); setEditing(false); setPreview(null); clearErrors()
|
||||
}
|
||||
async function save(calendar: SupportCalendar) {
|
||||
if (mutation.current || !writable || !editing || !isDirty || !mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
const validation = calendarValidationKey(calendar)
|
||||
if (validation) { setError("root", { message: t(validation) }); return }
|
||||
if (!(await confirm({ title: t("supportAvailability.confirmCalendarTitle"), description: initial.mode === "legacy" && calendar.mode === "recurring" ? t("supportAvailability.confirmRecurring") : t("supportAvailability.confirmCalendar"), confirmText: t("supportAvailability.saveCalendar") }))) return
|
||||
if (!mounted.current || mutation.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
mutation.current = true; setSaving(true); clearErrors()
|
||||
try {
|
||||
const result = await updateSupportCalendar(calendar)
|
||||
if (!mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
// Re-read on the page to obtain server-normalized effective timezone and arrays.
|
||||
reset({ ...result, weekly: result.weekly ?? [], exceptions: (result.exceptions ?? []).map(day => ({ ...day, periods: day.periods ?? [] })) })
|
||||
setEditing(false); setPreview(null); toast.success(t("supportAvailability.calendarSaved")); notifyAvailabilityUpdated(); onSaved(result)
|
||||
} catch (error) { if (mounted.current) setError("root", { message: error instanceof Error ? error.message : t("supportAvailability.saveFailed") }) }
|
||||
finally { mutation.current = false; if (mounted.current) setSaving(false) }
|
||||
}
|
||||
async function runPreview() {
|
||||
const validation = calendarValidationKey(draft)
|
||||
if (validation) { setPreviewError(t(validation)); return }
|
||||
const at = previewAt ? selectedLocalDateTimeISO(previewAt, previewOccurrence) : undefined
|
||||
if (previewAt && !at) { setPreviewError(t("supportAvailability.invalidPreviewTime")); return }
|
||||
const requestId = ++previewRequest.current
|
||||
setPreviewing(true); setPreviewError("")
|
||||
try {
|
||||
const result = await previewSupportCalendar(draft, at ?? undefined)
|
||||
if (mounted.current && previewRequest.current === requestId) { previewFingerprint.current = `${fingerprint}:${previewAt}:${previewOccurrence}`; setPreview(result) }
|
||||
} catch (error) { if (mounted.current && previewRequest.current === requestId) { setPreview(null); setPreviewError(error instanceof Error ? error.message : t("supportAvailability.previewFailed")) } }
|
||||
finally { if (mounted.current && previewRequest.current === requestId) setPreviewing(false) }
|
||||
}
|
||||
const previewCurrent = preview && previewFingerprint.current === `${fingerprint}:${previewAt}:${previewOccurrence}`
|
||||
return <form onSubmit={handleSubmit(save)} className="space-y-6" noValidate>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
|
||||
<h2 className="text-base font-semibold">{t("supportAvailability.calendar")}</h2>
|
||||
{writable ? editing ? <div className="flex gap-2"><Button variant="outline" type="button" disabled={saving} onClick={() => void cancel()}>{t("supportAvailability.cancel")}</Button><Button type="submit" disabled={saving || !isDirty}>{saving ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(saving ? "supportAvailability.saving" : "supportAvailability.saveCalendar")}</Button></div> : <Button variant="outline" type="button" onClick={() => setEditing(true)}>{t("supportAvailability.editCalendar")}</Button> : <p className="text-sm text-muted-foreground">{t("supportAvailability.readOnly")}</p>}
|
||||
</div>
|
||||
{errors.root?.message ? <p role="alert" className="text-sm text-destructive">{errors.root.message}</p> : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="availability-timezone">{t(initial.teamId === 0 ? "supportAvailability.deploymentTimezone" : "supportAvailability.teamTimezone")}</FieldLabel><Input id="availability-timezone" placeholder={initial.teamId ? initial.deploymentTimezone : "Europe/Moscow"} disabled={locked} {...register("timezone")} /><p className="text-xs text-muted-foreground">{t(initial.teamId ? "supportAvailability.inheritTimezoneHelp" : "supportAvailability.timezoneHelp")}</p><p className="text-sm">{t("supportAvailability.effectiveTimezone", { timezone: effectiveTimezone })}</p></Field>
|
||||
{initial.teamId !== 0 ? <Field><FieldLabel>{t("supportAvailability.scheduleMode")}</FieldLabel><Controller control={control} name="mode" render={({ field }) => <OptionCombobox value={field.value} options={[{ value: "legacy", label: t("supportAvailability.legacy") }, { value: "recurring", label: t("supportAvailability.recurring") }]} onChange={field.onChange} disabled={locked} ariaLabel={t("supportAvailability.scheduleMode")} placeholder={t("supportAvailability.scheduleMode")} />} /></Field> : null}
|
||||
</div>
|
||||
{initial.teamId !== 0 ? <><p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">{t("supportAvailability.legacyHelp")} <Link href="/dashboard/agent-team-schedules" className="underline underline-offset-2">{t("supportAvailability.viewDatedShifts")}</Link></p>
|
||||
{draft.mode === "recurring" ? <>
|
||||
<section className="space-y-3" aria-labelledby="weekly-hours-title"><div className="flex flex-wrap items-center justify-between gap-2"><h3 id="weekly-hours-title" className="text-sm font-semibold">{t("supportAvailability.weeklyHours")}</h3>{editing ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => weekly.append({ weekday: 1, start: "09:00", end: "17:00" })}><PlusIcon className="size-4" />{t("supportAvailability.addPeriod")}</Button> : null}</div><p className="text-xs text-muted-foreground">{t("supportAvailability.hoursHelp")}</p>
|
||||
{weekly.fields.length === 0 ? <p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">{t("supportAvailability.noWeeklyHours")}</p> : weekly.fields.map((period, index) => <div key={period.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2 rounded-md border p-3 sm:grid-cols-[minmax(8rem,1fr)_1fr_1fr_auto]">
|
||||
<Field className="col-span-2 sm:col-span-1"><FieldLabel>{t("supportAvailability.day")}</FieldLabel><Controller control={control} name={`weekly.${index}.weekday`} render={({ field }) => <OptionCombobox value={String(field.value)} onChange={value => field.onChange(Number(value))} options={weekdays} disabled={locked} ariaLabel={t("supportAvailability.day")} placeholder={t("supportAvailability.day")} />} /></Field>
|
||||
<Field><FieldLabel htmlFor={`weekly-start-${period.id}`}>{t("supportAvailability.start")}</FieldLabel><Input id={`weekly-start-${period.id}`} type="time" disabled={locked} {...register(`weekly.${index}.start`)} /></Field><Field><FieldLabel htmlFor={`weekly-end-${period.id}`}>{t("supportAvailability.end")}</FieldLabel><Input id={`weekly-end-${period.id}`} type="text" inputMode="text" placeholder="HH:mm" disabled={locked} {...register(`weekly.${index}.end`)} /></Field>
|
||||
{editing ? <Button className="justify-self-end" type="button" variant="ghost" size="icon" aria-label={t("supportAvailability.removePeriod")} disabled={saving} onClick={() => weekly.remove(index)}><Trash2Icon className="size-4" /></Button> : <span />}
|
||||
</div>)}
|
||||
</section>
|
||||
<section className="space-y-3" aria-labelledby="date-exceptions-title"><div className="flex flex-wrap items-center justify-between gap-2"><h3 id="date-exceptions-title" className="text-sm font-semibold">{t("supportAvailability.exceptions")}</h3>{editing ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => exceptions.append({ date: "", closed: true, periods: [] })}><PlusIcon className="size-4" />{t("supportAvailability.addException")}</Button> : null}</div><p className="text-xs text-muted-foreground">{t("supportAvailability.exceptionsHelp")}</p>
|
||||
{!exceptions.fields.length ? <p className="text-sm text-muted-foreground">{t("supportAvailability.noExceptions")}</p> : exceptions.fields.map((day, index) => <div key={day.id} className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-end gap-3"><Field className="w-auto min-w-44 flex-1"><FieldLabel htmlFor={`exception-date-${day.id}`}>{t("supportAvailability.date")}</FieldLabel><Input id={`exception-date-${day.id}`} type="date" disabled={locked} {...register(`exceptions.${index}.date`)} /></Field><Field className="w-auto pb-2"><FieldLabel htmlFor={`exception-closed-${day.id}`}>{t("supportAvailability.closedDay")}</FieldLabel><Controller control={control} name={`exceptions.${index}.closed`} render={({ field }) => <Switch id={`exception-closed-${day.id}`} checked={field.value} onCheckedChange={field.onChange} disabled={locked} />} /></Field>{editing ? <Button variant="ghost" type="button" size="icon" disabled={saving} aria-label={t("supportAvailability.removeException")} onClick={() => exceptions.remove(index)}><Trash2Icon className="size-4" /></Button> : null}</div>
|
||||
{!draft.exceptions[index]?.closed ? <Controller control={control} name={`exceptions.${index}.periods`} render={({ field }) => <ExceptionPeriods value={field.value ?? []} onChange={field.onChange} disabled={locked} id={day.id} />} /> : null}
|
||||
</div>)}
|
||||
</section>
|
||||
</> : <p className="text-sm text-muted-foreground">{t("supportAvailability.legacyActive")}</p>}
|
||||
<section className="space-y-3 rounded-md border p-4" aria-labelledby="calendar-preview-title"><h3 id="calendar-preview-title" className="text-sm font-semibold">{t("supportAvailability.preview")}</h3><p className="text-xs text-muted-foreground">{t("supportAvailability.previewHelp", { timezone: browserTimezone })}</p><div className="flex flex-wrap items-end gap-3"><Field className="min-w-48 flex-1"><FieldLabel htmlFor="preview-at">{t("supportAvailability.previewAt")}</FieldLabel><Input id="preview-at" type="datetime-local" value={previewAt} onChange={event => setPreviewAt(event.target.value)} disabled={previewing || saving} /></Field><Button type="button" variant="outline" disabled={previewing || saving} onClick={() => void runPreview()}>{previewing ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(previewing ? "supportAvailability.previewing" : "supportAvailability.runPreview")}</Button></div>
|
||||
{localDateTimeCandidates(previewAt).length > 1 ? <Field><FieldLabel>{t("supportAvailability.dstOccurrence", { field: t("supportAvailability.previewAt") })}</FieldLabel><OptionCombobox value={selectedLocalDateTimeISO(previewAt, previewOccurrence) ?? ""} options={localDateTimeCandidates(previewAt).map(value => ({ value, label: value }))} onChange={setPreviewOccurrence} disabled={previewing} ariaLabel={t("supportAvailability.chooseOccurrence")} placeholder={t("supportAvailability.chooseOccurrence")} /></Field> : null}
|
||||
{previewError ? <p role="alert" className="text-sm text-destructive">{previewError}</p> : null}
|
||||
{previewCurrent ? <div className="space-y-1 text-sm" role="status"><p>{t(preview.open ? "supportAvailability.scheduleOpen" : "supportAvailability.scheduleClosed")}</p><p>{t("supportAvailability.effectiveTimezone", { timezone: preview.timezone })}</p>{preview.nextOpeningAt ? <p>{t("supportAvailability.nextOpening", { time: formatAvailabilityTime(preview.nextOpeningAt, preview.timezone, locale) })}</p> : !preview.open ? <p>{t("supportAvailability.noNextOpening")}</p> : null}<p className="text-xs text-muted-foreground">{t("supportAvailability.notPresencePromise")}</p></div> : preview ? <p className="text-xs text-muted-foreground">{t("supportAvailability.previewStale")}</p> : null}
|
||||
</section></> : null}
|
||||
</form>
|
||||
}
|
||||
|
||||
function ExceptionPeriods({ value, onChange, disabled, id }: { value: SupportPeriod[]; onChange: (value: SupportPeriod[]) => void; disabled: boolean; id: string }) {
|
||||
const t = useI18n()
|
||||
return <div className="space-y-2">{value.map((period, index) => <div key={index} className="flex items-end gap-2"><Field><FieldLabel htmlFor={`${id}-start-${index}`}>{t("supportAvailability.start")}</FieldLabel><Input id={`${id}-start-${index}`} type="time" value={period.start} disabled={disabled} onChange={event => onChange(value.map((p, i) => i === index ? { ...p, start: event.target.value } : p))} /></Field><Field><FieldLabel htmlFor={`${id}-end-${index}`}>{t("supportAvailability.end")}</FieldLabel><Input id={`${id}-end-${index}`} type="text" inputMode="text" placeholder="HH:mm" value={period.end} disabled={disabled} onChange={event => onChange(value.map((p, i) => i === index ? { ...p, end: event.target.value } : p))} /></Field>{!disabled ? <Button type="button" variant="ghost" size="icon" aria-label={t("supportAvailability.removePeriod")} onClick={() => onChange(value.filter((_, i) => i !== index))}><Trash2Icon className="size-4" /></Button> : null}</div>)}{!disabled ? <Button type="button" variant="outline" size="sm" onClick={() => onChange([...value, { start: "09:00", end: "17:00" }])}><PlusIcon className="size-4" />{t("supportAvailability.addPeriod")}</Button> : null}</div>
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { DashboardPage, DashboardToolbar } from "@/components/dashboard-page"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { OperatorStatusControl } from "@/components/support-availability/operator-status"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchAgentTeamsAll, type AdminAgentTeam } from "@/lib/api/admin"
|
||||
import { fetchSupportCalendar, type SupportCalendar } from "@/lib/api/support-availability"
|
||||
import { CalendarEditor } from "./_components/calendar-editor"
|
||||
|
||||
// A scoped configuration editor, not a paginated CRUD resource.
|
||||
export default function SupportAvailabilityPage() {
|
||||
const { session } = useAuth()
|
||||
return <SupportAvailabilityContent key={session?.user.id ?? "signed-out"} />
|
||||
}
|
||||
function SupportAvailabilityContent() {
|
||||
const t = useI18n()
|
||||
const { session } = useAuth()
|
||||
const confirm = useConfirm()
|
||||
const can = (permission: string) => Boolean(session?.permissions.includes(permission))
|
||||
const canDefault = can("supportConfig.view")
|
||||
const canTeams = can("agentTeam.view") && can("agentTeamSchedule.view")
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [teamId, setTeamId] = useState<number | null>(canDefault ? 0 : null)
|
||||
const [calendar, setCalendar] = useState<SupportCalendar | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [teamError, setTeamError] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [revision, setRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!canTeams) return
|
||||
let active = true
|
||||
fetchAgentTeamsAll().then(data => { if (active) { setTeams(data); setTeamError(""); setTeamId(current => current ?? data[0]?.id ?? null); if (!data.length && !canDefault) setLoading(false) } }).catch(error => { if (active) { setTeamError(error instanceof Error ? error.message : t("supportAvailability.loadFailed")); if (!canDefault) setLoading(false) } })
|
||||
return () => { active = false }
|
||||
}, [canTeams, canDefault, revision, t])
|
||||
useEffect(() => {
|
||||
if (teamId === null) return
|
||||
let active = true
|
||||
fetchSupportCalendar(teamId).then(data => { if (active) { setCalendar(data); setError("") } }).catch(error => { if (active) { setCalendar(null); setError(error instanceof Error ? error.message : t("supportAvailability.loadFailed")) } }).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [teamId, revision, t])
|
||||
async function changeScope(next: number) {
|
||||
if (busy) return
|
||||
if (dirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
setDirty(false); setCalendar(null); setLoading(true); setTeamId(next)
|
||||
}
|
||||
const options = [...(canDefault ? [{ value: "0", label: t("supportAvailability.deploymentDefault") }] : []), ...teams.map(team => ({ value: String(team.id), label: team.name }))]
|
||||
return <DashboardPage>
|
||||
<DashboardToolbar actions={<OperatorStatusControl />}><div><h1 className="text-lg font-semibold">{t("supportAvailability.title")}</h1><p className="mt-1 max-w-3xl text-sm text-muted-foreground">{t("supportAvailability.description")}</p></div></DashboardToolbar>
|
||||
{!canDefault && !canTeams ? <p className="text-sm text-muted-foreground">{t("supportAvailability.noPermission")}</p> : <>
|
||||
<div className="flex max-w-xl items-center gap-2"><div className="min-w-0 flex-1"><OptionCombobox value={teamId === null ? "" : String(teamId)} disabled={busy} options={options} onChange={value => void changeScope(Number(value))} ariaLabel={t("supportAvailability.selectScope")} placeholder={t("supportAvailability.selectScope")} /></div><Button type="button" variant="outline" size="icon" aria-label={t("supportAvailability.refresh")} disabled={dirty || busy} onClick={() => { setLoading(true); setRevision(value => value + 1) }}><RefreshCwIcon className="size-4" /></Button></div>
|
||||
{teamError ? <p role="alert" className="text-sm text-destructive">{teamError}</p> : null}
|
||||
{error ? <div role="alert" className="space-y-2 rounded-md border p-4"><p className="text-sm text-destructive">{error}</p><Button variant="outline" onClick={() => { setLoading(true); setRevision(value => value + 1) }}>{t("supportAvailability.retry")}</Button></div> : calendar && calendar.teamId === teamId ? <CalendarEditor key={`${teamId}:${revision}`} initial={calendar} writable={can(teamId === 0 ? "supportConfig.update" : "agentTeamSchedule.update")} onDirtyChange={setDirty} onBusyChange={setBusy} onSaved={value => { setCalendar(value); setDirty(false); setRevision(revision => revision + 1) }} /> : <p className="p-4 text-sm text-muted-foreground" role="status">{t(teamId === null && !loading ? "supportAvailability.noTeams" : "supportAvailability.loading")}</p>}
|
||||
</>}
|
||||
</DashboardPage>
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
@@ -9,15 +8,15 @@ const providerSource = await readFile(
|
||||
"utf8",
|
||||
).catch(() => "");
|
||||
const workbenchLayoutSource = await readFile(
|
||||
new URL("../app/(dashboard)/workbench/layout.tsx", import.meta.url),
|
||||
new URL("../app/workbench/layout.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const dashboardConversationsPageSource = await readFile(
|
||||
new URL("../app/(dashboard)/dashboard/conversations/page.tsx", import.meta.url),
|
||||
new URL("../app/dashboard/conversations/page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const conversationWorkbenchSource = await readFile(
|
||||
new URL("../app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
|
||||
new URL("../app/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -197,9 +196,6 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(allowedModes.length > 1
|
||||
? [{ key: "separator-mode", type: "separator" } as const]
|
||||
: []),
|
||||
{
|
||||
key: "bold",
|
||||
label: t("editor.bold"),
|
||||
@@ -341,6 +337,10 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
|
||||
},
|
||||
]
|
||||
|
||||
if (allowedModes.length > 1) {
|
||||
actions.splice(1, 0, { key: "separator-mode", type: "separator" })
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user