diff --git a/docs/test-architecture.md b/docs/test-architecture.md index 2e938cda0..660d85862 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -150,6 +150,17 @@ run does not prove for each one; the taxonomy and cross-repository entries live clerks list, not that the API returns those records or the logged-in staff member's `verifiedName`. The session is a synthetic unsigned JWT, so a green run also does not prove login or token verification. +- **The RealUnit support visual spec answers the support endpoints itself.** + `e2e/realunit-support.spec.ts` fulfils `GET /v1/realunit/support/list`, `/counts`, `/activity`, + `/clerks`, `/:id/data` and `/:id/messages` with synthetic `{ userDataId, name }[]` clerks and + `clerkUserDataId` on the issue fixture. A green run proves those fixtures render. It does not + prove that the API returns object clerks, that assignment writes `clerkUserDataId`, or that + login works — auth is a real admin token, feature data is not. +- **The DFX support issue visual spec answers the issue endpoints itself.** + `e2e/support-dashboard-issue.spec.ts` uses a synthetic unsigned Admin JWT and fulfils + `GET /v1/support/issue/clerks`, `/v1/support/issue/:id/data`, `/v1/support/issue/:uid` + (message thread) and staff bootstrap GETs. A green run proves the issue screen renders + that fixture, not that the API returns it or that login works. - **Full-stack guest assign/refund specs SQL-write `transaction.actionSecretHash`.** `e2e-stack/specs/transactions.spec.ts` (`seedActionSecret`) updates the hash directly. A green run does **not** prove that the mail/API path creates, hashes, or delivers the action secret. diff --git a/e2e/realunit-support.spec.ts b/e2e/realunit-support.spec.ts index e66280992..22e69b040 100644 --- a/e2e/realunit-support.spec.ts +++ b/e2e/realunit-support.spec.ts @@ -119,6 +119,7 @@ interface SupportIssueInternalData { state: string; name: string; clerk?: string; + clerkUserDataId?: number; account: SupportIssueInternalAccountData; } @@ -215,7 +216,10 @@ const COUNTS: Record = { Completed: 9, }; -const CLERKS: string[] = ['Rita Clerk', 'Tom Support']; +const CLERKS: { userDataId: number; name: string }[] = [ + { userDataId: 101, name: 'Rita Clerk' }, + { userDataId: 102, name: 'Tom Support' }, +]; // Detail for ISSUE_ID (7001), matching the OPEN_ISSUES[0] header fields. const ISSUE_DATA: SupportIssueInternalData = { @@ -228,6 +232,7 @@ const ISSUE_DATA: SupportIssueInternalData = { state: 'Pending', name: 'Alice Muster', clerk: 'Rita Clerk', + clerkUserDataId: 101, account: { id: 8001, status: 'Active', diff --git a/e2e/screenshots/baseline/support-dashboard-issue.spec.ts-support-dashboard-02-issue-chromium-darwin.png b/e2e/screenshots/baseline/support-dashboard-issue.spec.ts-support-dashboard-02-issue-chromium-darwin.png new file mode 100644 index 000000000..bddacfe56 Binary files /dev/null and b/e2e/screenshots/baseline/support-dashboard-issue.spec.ts-support-dashboard-02-issue-chromium-darwin.png differ diff --git a/e2e/support-dashboard-issue.spec.ts b/e2e/support-dashboard-issue.spec.ts new file mode 100644 index 000000000..c906d2420 --- /dev/null +++ b/e2e/support-dashboard-issue.spec.ts @@ -0,0 +1,139 @@ +import { test, expect, Page, Route } from '@playwright/test'; + +/** + * E2E Visual Regression Test: DFX Support issue detail + * + * Auth is a synthetic Admin JWT. Staff GETs and the issue endpoints are mocked, so + * the suite does not need a live API. See docs/test-architecture.md. + */ + +const CUSTOMER_AUTHOR = 'Customer'; +const ISSUE_ID = 7001; +const ISSUE_UID = 'SI-7001-UID'; + +function jwt(): string { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode({ + account: 1, + user: 1, + role: 'Admin', + exp: Math.floor(Date.now() / 1000) + 3600, + })}.synthetic`; +} + +const CLERKS = [ + { userDataId: 101, name: 'Rita Clerk' }, + { userDataId: 102, name: 'Tom Support' }, +]; + +const ISSUE_DATA = { + id: ISSUE_ID, + created: '2024-01-01T09:00:00.000Z', + uid: ISSUE_UID, + type: 'TransactionIssue', + department: 'Support', + reason: 'FundsNotReceived', + state: 'Pending', + name: 'Alice Muster', + clerk: 'Rita Clerk', + clerkUserDataId: 101, + account: { + id: 8001, + status: 'Active', + verifiedName: 'Alice Muster', + completeName: 'Alice Muster', + accountType: 'Personal', + kycLevel: '50', + depositLimit: 100000, + annualVolume: 25000, + kycHash: 'a1b2c3d4e5', + country: { name: 'Switzerland' }, + language: { name: 'English', symbol: 'EN' }, + }, +}; + +const MESSAGES = [ + { + id: 501, + author: CUSTOMER_AUTHOR, + message: 'Hello, I did not receive my funds for the last transaction.', + created: '2024-01-01T09:05:00.000Z', + }, + { + id: 502, + author: 'Rita Clerk', + message: 'Hi Alice, thanks for reaching out.', + created: '2024-01-01T10:30:00.000Z', + }, +]; + +const CLERKS_RE = /\/v1\/support\/issue\/clerks(?:\?|$)/; +const DATA_RE = /\/v1\/support\/issue\/(\d+)\/data(?:\?|$)/; +const THREAD_RE = /\/v1\/support\/issue\/SI-7001-UID(?:\?|$)/; + +async function json(route: Route, body: unknown): Promise { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); +} + +async function installIssueRoutes(page: Page): Promise { + await page.route('**/v1/**', async (route: Route) => { + const request = route.request(); + const url = request.url(); + const path = new URL(url).pathname; + + if (CLERKS_RE.test(url)) return json(route, CLERKS); + if (DATA_RE.test(url)) return json(route, ISSUE_DATA); + if (THREAD_RE.test(url) && request.method() === 'GET') return json(route, { messages: MESSAGES }); + + if ( + request.method() === 'GET' && + ['/v1/language', '/v1/fiat', '/v1/asset', '/v1/bankAccount', '/v1/country'].includes(path) + ) { + return json(route, []); + } + if (request.method() === 'GET' && path === '/v1/setting/infoBanner') { + return json(route, null); + } + if (request.method() === 'GET' && path === '/v1/support/issue/clerk') { + return json(route, { clerkUserDataId: 1, clerk: 'Rita Clerk' }); + } + + await route.continue(); + }); + + await page.route('**/v2/**', async (route: Route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (request.method() === 'GET' && path === '/v2/user') { + return json(route, { + id: 1, + activeAddress: { address: '0x0000000000000000000000000000000000000001', wallet: 'DFX' }, + addresses: [], + kyc: { level: 50, status: 'Completed' }, + language: { id: 1, name: 'English', symbol: 'EN' }, + }); + } + await route.continue(); + }); +} + +test.describe('Support Dashboard - issue detail', () => { + const token = jwt(); + + test('issue screen shows detail panels, clerk select and message thread', async ({ page }) => { + await installIssueRoutes(page); + + await page.goto(`/support/dashboard/issue/${ISSUE_ID}?session=${encodeURIComponent(token)}&lang=en`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1500); + + await expect(page.getByText('Issue Details')).toBeVisible(); + await expect(page.getByText(ISSUE_UID)).toBeVisible(); + await expect(page.locator('select').filter({ hasText: 'Rita Clerk' })).toHaveValue('101'); + + await expect(page).toHaveScreenshot('support-dashboard-02-issue.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); +}); diff --git a/scripts/handbook/metadata.json b/scripts/handbook/metadata.json index 8cd77857e..4c543b42f 100644 --- a/scripts/handbook/metadata.json +++ b/scripts/handbook/metadata.json @@ -161,7 +161,7 @@ }, "support-dashboard": { "title": "Support-Dashboard", - "description": "Support-Dashboard: Übersicht und Detailansichten." + "description": "Support-Dashboard: Übersicht der offenen Tickets und die Ticket-Detailansicht mit Clerk-Zuweisung." }, "support-dashboard-overview": { "title": "Support-Dashboard Übersicht", diff --git a/src/__tests__/realunit-dashboard.hook.test.ts b/src/__tests__/realunit-dashboard.hook.test.ts index fbfcb8f23..98791b597 100644 --- a/src/__tests__/realunit-dashboard.hook.test.ts +++ b/src/__tests__/realunit-dashboard.hook.test.ts @@ -27,6 +27,12 @@ jest.mock('src/util/utils', () => ({ import { ResponseType } from '@dfx.swiss/react'; import { useRealunitCompliance } from '../hooks/realunit-compliance.hook'; import { useRealunitSupport } from '../hooks/realunit-support.hook'; +import { + clerkAssignmentPayload, + isAssignedToMe, + LEFTOVER_CLERK_VALUE, + usableClerks, +} from '../hooks/support-dashboard.hook'; describe('useRealunitSupport', () => { beforeEach(() => { @@ -60,11 +66,11 @@ describe('useRealunitSupport', () => { await result.current.getIssueData(42); expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/support/42/data', method: 'GET' }); - await result.current.updateIssue(42, { state: 'Completed', clerk: 'Alice' }); + await result.current.updateIssue(42, { state: 'Completed', clerkUserDataId: 9 }); expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/support/42', method: 'PUT', - data: { state: 'Completed', clerk: 'Alice' }, + data: { state: 'Completed', clerkUserDataId: 9 }, }); await result.current.createMessage(42, { author: 'Alice', message: 'hi' }); @@ -88,14 +94,35 @@ describe('useRealunitSupport', () => { expect(messages).toEqual([{ id: 1, author: 'Alice', created: 'now' }]); }); + it('getClerks returns { userDataId, name }[] from GET realunit/support/clerks', async () => { + mockCall.mockResolvedValue([{ userDataId: 3, name: 'Alex' }]); + const { result } = renderHook(() => useRealunitSupport()); + + const clerks = await result.current.getClerks(); + + expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/support/clerks', method: 'GET' }); + expect(clerks).toEqual([{ userDataId: 3, name: 'Alex' }]); + }); + + it('getClerks drops entries without a finite userDataId', async () => { + mockCall.mockResolvedValue([ + { userDataId: 3, name: 'Alex' }, + { userDataId: Number.NaN, name: 'Broken' }, + { name: 'NoId' }, + ]); + const { result } = renderHook(() => useRealunitSupport()); + + await expect(result.current.getClerks()).resolves.toEqual([{ userDataId: 3, name: 'Alex' }]); + }); + it('getMyClerk GETs realunit/support/clerk and trims the clerk name', async () => { - mockCall.mockResolvedValue({ clerk: ' Ada ' }); + mockCall.mockResolvedValue({ clerkUserDataId: 7, clerk: ' Ada ' }); const { result } = renderHook(() => useRealunitSupport()); const clerk = await result.current.getMyClerk(); expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/support/clerk', method: 'GET' }); - expect(clerk).toBe('Ada'); + expect(clerk).toEqual({ clerkUserDataId: 7, clerk: 'Ada' }); }); it('getMyClerk returns undefined when clerk is null or blank', async () => { @@ -109,6 +136,69 @@ describe('useRealunitSupport', () => { }); }); +describe('clerkAssignmentPayload', () => { + it('omits the field when the selected clerk is unchanged', () => { + expect(clerkAssignmentPayload('101', 101)).toEqual({}); + }); + + it('sends the id when assigning a different clerk', () => { + expect(clerkAssignmentPayload('102', 101)).toEqual({ clerkUserDataId: 102 }); + }); + + it('sends null when clearing an existing assignment', () => { + expect(clerkAssignmentPayload('', 101)).toEqual({ clerkUserDataId: null }); + }); + + it('omits the field when already unassigned and the select is empty', () => { + expect(clerkAssignmentPayload('', null)).toEqual({}); + expect(clerkAssignmentPayload('')).toEqual({}); + }); + + it('sends null when the leftover name is still set and the select is empty', () => { + expect(clerkAssignmentPayload('', null, { leftover: true })).toEqual({ clerkUserDataId: null }); + }); + + it('omits the field while the leftover name is still selected', () => { + expect(clerkAssignmentPayload(LEFTOVER_CLERK_VALUE, null, { leftover: true })).toEqual({}); + }); + + it('omits the field when the selected value is not a finite id', () => { + expect(clerkAssignmentPayload('undefined', 101)).toEqual({}); + expect(clerkAssignmentPayload('NaN', 101)).toEqual({}); + }); + + it('omits the field when the id is not on the allow list', () => { + expect(clerkAssignmentPayload('99', null, { allowedIds: [101, 102] })).toEqual({}); + expect(clerkAssignmentPayload('101', null, { allowedIds: [101, 102] })).toEqual({ clerkUserDataId: 101 }); + }); +}); + +describe('isAssignedToMe', () => { + it('matches the JWT account even when the leftover name differs', () => { + expect(isAssignedToMe({ clerkUserDataId: 7, clerk: 'Josh' }, 7, 'JOSHUA BEN KRUEGER')).toBe(true); + }); + + it('matches a leftover name when the id is still missing', () => { + expect(isAssignedToMe({ clerk: 'Ada' }, 7, 'Ada')).toBe(true); + }); + + it('does not match a leftover name against a different session', () => { + expect(isAssignedToMe({ clerkUserDataId: 9, clerk: 'Ada' }, 7, 'Ada')).toBe(false); + }); +}); + +describe('usableClerks', () => { + it('keeps only entries with a finite userDataId and a name', () => { + expect( + usableClerks([ + { userDataId: 1, name: 'Ada' }, + { userDataId: Number.NaN, name: 'Bad' }, + { userDataId: 2, name: '' }, + ]), + ).toEqual([{ userDataId: 1, name: 'Ada' }]); + }); +}); + describe('useRealunitCompliance', () => { beforeEach(() => { mockCall.mockReset().mockResolvedValue(undefined); diff --git a/src/__tests__/staff-verified-name.hook.test.ts b/src/__tests__/staff-verified-name.hook.test.ts index 65afd865f..d6cae3d89 100644 --- a/src/__tests__/staff-verified-name.hook.test.ts +++ b/src/__tests__/staff-verified-name.hook.test.ts @@ -40,7 +40,7 @@ describe('useStaffVerifiedName', () => { }); it('loads the trimmed clerk name from the support endpoint', async () => { - mockGetSupportClerk.mockResolvedValue(' Ada Lovelace '); + mockGetSupportClerk.mockResolvedValue({ clerkUserDataId: 42, clerk: ' Ada Lovelace ' }); const { result } = renderHook(() => useStaffVerifiedName()); @@ -58,7 +58,7 @@ describe('useStaffVerifiedName', () => { it('uses the RealUnit clerk endpoint and does not call getUserData when clerk is present', async () => { mockAuth.session = { account: 42, role: 'RealUnit' }; - mockGetRealunitClerk.mockResolvedValue('Real Unit Clerk'); + mockGetRealunitClerk.mockResolvedValue({ clerkUserDataId: 42, clerk: 'Real Unit Clerk' }); const { result } = renderHook(() => useStaffVerifiedName()); @@ -85,7 +85,7 @@ describe('useStaffVerifiedName', () => { }); it('falls back to getUserData when clerk is blank', async () => { - mockGetSupportClerk.mockResolvedValue(' '); + mockGetSupportClerk.mockResolvedValue({ clerkUserDataId: 42, clerk: ' ' }); mockGetUserData.mockResolvedValue({ userData: { verifiedName: 'Ada Lovelace' } }); const { result } = renderHook(() => useStaffVerifiedName()); @@ -181,8 +181,8 @@ describe('useStaffVerifiedName', () => { }); it('reloads from the RealUnit endpoint when the same account changes role', async () => { - mockGetSupportClerk.mockResolvedValue('Ada Lovelace'); - mockGetRealunitClerk.mockResolvedValue('Real Unit Clerk'); + mockGetSupportClerk.mockResolvedValue({ clerkUserDataId: 42, clerk: 'Ada Lovelace' }); + mockGetRealunitClerk.mockResolvedValue({ clerkUserDataId: 42, clerk: 'Real Unit Clerk' }); const { result, rerender } = renderHook(() => useStaffVerifiedName()); await waitFor(() => expect(result.current.name).toBe('Ada Lovelace')); @@ -201,8 +201,8 @@ describe('useStaffVerifiedName', () => { }); it('reuses the in-flight request for the same account', async () => { - let resolveRequest!: (value: string | undefined) => void; - const request = new Promise((resolve) => { + let resolveRequest!: (value: { clerkUserDataId: number; clerk: string } | undefined) => void; + const request = new Promise<{ clerkUserDataId: number; clerk: string } | undefined>((resolve) => { resolveRequest = resolve; }); mockGetSupportClerk.mockReturnValue(request); @@ -213,7 +213,7 @@ describe('useStaffVerifiedName', () => { expect(mockGetSupportClerk).toHaveBeenCalledTimes(1); await act(async () => { - resolveRequest('Ada Lovelace'); + resolveRequest({ clerkUserDataId: 42, clerk: 'Ada Lovelace' }); await request; }); @@ -224,7 +224,8 @@ describe('useStaffVerifiedName', () => { it('does not keep the previous name while another account is loading', async () => { mockGetSupportClerk.mockImplementation(() => { const account = mockAuth.session?.account; - return Promise.resolve(account === 42 ? 'Ada Lovelace' : 'Grace Hopper'); + const clerk = account === 42 ? 'Ada Lovelace' : 'Grace Hopper'; + return Promise.resolve({ clerkUserDataId: account ?? 0, clerk }); }); const { result, rerender } = renderHook(() => useStaffVerifiedName()); @@ -241,8 +242,8 @@ describe('useStaffVerifiedName', () => { }); it('ignores a resolved request after unmounting', async () => { - let resolveRequest!: (value: string | undefined) => void; - const request = new Promise((resolve) => { + let resolveRequest!: (value: { clerkUserDataId: number; clerk: string } | undefined) => void; + const request = new Promise<{ clerkUserDataId: number; clerk: string } | undefined>((resolve) => { resolveRequest = resolve; }); mockGetSupportClerk.mockReturnValue(request); @@ -251,7 +252,7 @@ describe('useStaffVerifiedName', () => { unmount(); await act(async () => { - resolveRequest('Ada Lovelace'); + resolveRequest({ clerkUserDataId: 42, clerk: 'Ada Lovelace' }); await Promise.resolve(); }); }); diff --git a/src/__tests__/support-dashboard.hook.test.ts b/src/__tests__/support-dashboard.hook.test.ts new file mode 100644 index 000000000..22d350733 --- /dev/null +++ b/src/__tests__/support-dashboard.hook.test.ts @@ -0,0 +1,80 @@ +import { renderHook } from '@testing-library/react'; + +const mockCall = jest.fn(); + +jest.mock('@dfx.swiss/react', () => ({ + useApi: () => ({ call: mockCall }), + Department: { SUPPORT: 'Support', COMPLIANCE: 'Compliance', MARKETING: 'Marketing' }, + TfaLevel: { STRICT: 'Strict' }, +})); + +jest.mock('../hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: jest.fn() }), +})); + +import { useSupportDashboard } from '../hooks/support-dashboard.hook'; + +describe('useSupportDashboard', () => { + beforeEach(() => { + mockCall.mockReset().mockResolvedValue(undefined); + }); + + it('getClerks returns { userDataId, name }[] from GET support/issue/clerks', async () => { + mockCall.mockResolvedValue([{ userDataId: 3, name: 'Alex' }]); + const { result } = renderHook(() => useSupportDashboard()); + + const clerks = await result.current.getClerks(); + + expect(mockCall).toHaveBeenCalledWith({ url: 'support/issue/clerks', method: 'GET' }); + expect(clerks).toEqual([{ userDataId: 3, name: 'Alex' }]); + }); + + it('getClerks drops entries without a finite userDataId', async () => { + mockCall.mockResolvedValue([ + { userDataId: 3, name: 'Alex' }, + { userDataId: Number.NaN, name: 'Broken' }, + ]); + const { result } = renderHook(() => useSupportDashboard()); + + await expect(result.current.getClerks()).resolves.toEqual([{ userDataId: 3, name: 'Alex' }]); + }); + + it('getMyClerk GETs support/issue/clerk and trims the clerk name', async () => { + mockCall.mockResolvedValue({ clerkUserDataId: 7, clerk: ' Ada ' }); + const { result } = renderHook(() => useSupportDashboard()); + + await expect(result.current.getMyClerk()).resolves.toEqual({ clerkUserDataId: 7, clerk: 'Ada' }); + expect(mockCall).toHaveBeenCalledWith({ url: 'support/issue/clerk', method: 'GET' }); + }); + + it('getMyClerk returns undefined when clerk is null', async () => { + mockCall.mockResolvedValue({ clerkUserDataId: 7, clerk: null }); + const { result } = renderHook(() => useSupportDashboard()); + + await expect(result.current.getMyClerk()).resolves.toBeUndefined(); + }); + + it('updateIssue PUTs clerkUserDataId', async () => { + const { result } = renderHook(() => useSupportDashboard()); + + await result.current.updateIssue(42, { state: 'Completed', clerkUserDataId: 9 }); + + expect(mockCall).toHaveBeenCalledWith({ + url: 'support/issue/42', + method: 'PUT', + data: { state: 'Completed', clerkUserDataId: 9 }, + }); + }); + + it('updateIssue PUTs null to unassign', async () => { + const { result } = renderHook(() => useSupportDashboard()); + + await result.current.updateIssue(42, { clerkUserDataId: null }); + + expect(mockCall).toHaveBeenCalledWith({ + url: 'support/issue/42', + method: 'PUT', + data: { clerkUserDataId: null }, + }); + }); +}); diff --git a/src/hooks/realunit-support.hook.ts b/src/hooks/realunit-support.hook.ts index 0a271b3e5..ef3d52ee5 100644 --- a/src/hooks/realunit-support.hook.ts +++ b/src/hooks/realunit-support.hook.ts @@ -1,9 +1,11 @@ import { useMemo } from 'react'; import { useGuardedApi } from './guarded-api.hook'; import { + SupportClerk, SupportIssueInternalData, SupportIssueListItem, SupportMessageInfo, + usableClerks, } from './support-dashboard.hook'; // RealUnit tenant support dashboard hook. Thin wrapper over the strictly customer-scoped `/v1/realunit/support/*` @@ -46,20 +48,21 @@ export function useRealunitSupport() { }); } - async function getClerks(): Promise { - return call({ + async function getClerks(): Promise { + const list = await call({ url: 'realunit/support/clerks', method: 'GET', }); + return usableClerks(list); } - // the clerk name mapped to the logged-in RealUnit support account (null if unmapped) - async function getMyClerk(): Promise { - const result = await call<{ clerk: string | null }>({ + async function getMyClerk(): Promise<{ clerkUserDataId: number; clerk: string } | undefined> { + const result = await call<{ clerkUserDataId: number; clerk: string | null }>({ url: 'realunit/support/clerk', method: 'GET', }); - return result.clerk?.trim() || undefined; + const clerk = result.clerk?.trim(); + return clerk ? { clerkUserDataId: result.clerkUserDataId, clerk } : undefined; } async function getIssueData(issueId: number): Promise { @@ -71,7 +74,7 @@ export function useRealunitSupport() { async function updateIssue( issueId: number, - data: { state?: string; clerk?: string; department?: string }, + data: { state?: string; clerkUserDataId?: number | null; department?: string }, ): Promise { return call({ url: `realunit/support/${issueId}`, diff --git a/src/hooks/staff-verified-name.hook.ts b/src/hooks/staff-verified-name.hook.ts index 5387d4a5c..b44d2afd3 100644 --- a/src/hooks/staff-verified-name.hook.ts +++ b/src/hooks/staff-verified-name.hook.ts @@ -41,7 +41,7 @@ export function useStaffVerifiedName(): { name?: string; isLoading: boolean; err if (!pending) { const loadClerk = role === UserRole.REALUNIT ? getRealunitClerk : getSupportClerk; pending = loadClerk() - .then((clerk) => readVerifiedName(clerk)) + .then((result) => readVerifiedName(result?.clerk)) // 404/403 while the companion API clerk route is not deployed yet: fall through to userData. .catch(() => undefined) .then((fromClerk) => { diff --git a/src/hooks/support-dashboard.hook.ts b/src/hooks/support-dashboard.hook.ts index ad653b210..d28a61143 100644 --- a/src/hooks/support-dashboard.hook.ts +++ b/src/hooks/support-dashboard.hook.ts @@ -15,6 +15,7 @@ export interface SupportIssueListItem { state: string; name: string; clerk?: string; + clerkUserDataId?: number; department?: string; created: string; updated?: string; @@ -79,6 +80,7 @@ export interface SupportIssueInternalData { state: string; name: string; clerk?: string; + clerkUserDataId?: number; account: SupportIssueInternalAccountData; transaction?: SupportIssueInternalTransactionData; limitRequest?: SupportIssueInternalLimitRequestData; @@ -123,6 +125,45 @@ export interface SupportStatisticsDto { resolutionByType: SupportResolutionBucket[]; } +export interface SupportClerk { + userDataId: number; + name: string; +} + +/** Select value while a leftover name is still shown and the id is missing. Not a finite id, so PUT omits. */ +export const LEFTOVER_CLERK_VALUE = 'leftover'; + +/** PUT payload for the clerk select: omit when unchanged, null to unassign, skip non-finite values. */ +export function clerkAssignmentPayload( + selected: string, + previousId?: number | null, + options?: { leftover?: boolean; allowedIds?: number[] }, +): { clerkUserDataId: number | null } | Record { + const previous = previousId ?? null; + if (selected === '') { + return previous != null || options?.leftover ? { clerkUserDataId: null } : {}; + } + const nextId = Number(selected); + if (!Number.isFinite(nextId)) return {}; + if (options?.allowedIds && !options.allowedIds.includes(nextId)) return {}; + if (nextId === previous) return {}; + return { clerkUserDataId: nextId }; +} + +export function usableClerks(clerks: SupportClerk[]): SupportClerk[] { + return clerks.filter((c) => Number.isFinite(c.userDataId) && c.name); +} + +/** Mine-filter: JWT account id wins; leftover clerk name only when the id is still missing. */ +export function isAssignedToMe( + issue: { clerkUserDataId?: number; clerk?: string }, + sessionAccount?: number | null, + verifiedName?: string | null, +): boolean { + if (issue.clerkUserDataId != null) return issue.clerkUserDataId === sessionAccount; + return !!verifiedName && !!issue.clerk && issue.clerk === verifiedName; +} + export function useSupportDashboard() { // staff endpoints answer with HTTP 403 { code: 'TFA_REQUIRED' } when the session still needs 2FA; // useGuardedApi routes that into the bearer-based 2FA flow instead of surfacing a raw error @@ -169,20 +210,21 @@ export function useSupportDashboard() { }); } - async function getClerks(): Promise { - return guardedCall({ + async function getClerks(): Promise { + const list = await guardedCall({ url: 'support/issue/clerks', method: 'GET', }); + return usableClerks(list); } - // the clerk name mapped to the logged-in support account (null if unmapped) - async function getMyClerk(): Promise { - const result = await guardedCall<{ clerk: string | null }>({ + async function getMyClerk(): Promise<{ clerkUserDataId: number; clerk: string } | undefined> { + const result = await guardedCall<{ clerkUserDataId: number; clerk: string | null }>({ url: 'support/issue/clerk', method: 'GET', }); - return result.clerk?.trim() || undefined; + const clerk = result.clerk?.trim(); + return clerk ? { clerkUserDataId: result.clerkUserDataId, clerk } : undefined; } async function getIssueData(issueId: number): Promise { @@ -194,7 +236,7 @@ export function useSupportDashboard() { async function updateIssue( issueId: number, - data: { state?: string; clerk?: string; department?: string }, + data: { state?: string; clerkUserDataId?: number | null; department?: string }, ): Promise { return guardedCall({ url: `support/issue/${issueId}`, diff --git a/src/screens/realunit-support-issue.screen.tsx b/src/screens/realunit-support-issue.screen.tsx index 2cad942c8..575cb529d 100644 --- a/src/screens/realunit-support-issue.screen.tsx +++ b/src/screens/realunit-support-issue.screen.tsx @@ -13,7 +13,14 @@ import { useRealunitSupport } from 'src/hooks/realunit-support.hook'; import { STAFF_NAME_MISSING, staffNameLoadError } from 'src/components/compliance/staff-identity'; import { useStaffVerifiedName } from 'src/hooks/staff-verified-name.hook'; import { useSplitPane } from 'src/hooks/split-pane.hook'; -import { ASSIGNABLE_DEPARTMENTS, SupportIssueInternalData, SupportMessageInfo } from 'src/hooks/support-dashboard.hook'; +import { + ASSIGNABLE_DEPARTMENTS, + clerkAssignmentPayload, + LEFTOVER_CLERK_VALUE, + SupportClerk, + SupportIssueInternalData, + SupportMessageInfo, +} from 'src/hooks/support-dashboard.hook'; import { formatDateTime, statusBadge } from 'src/util/compliance-helpers'; import { reasonLabel, typeLabel } from 'src/util/support-helpers'; import { toBase64 } from 'src/util/utils'; @@ -33,7 +40,7 @@ export default function RealunitSupportIssueScreen(): JSX.Element { const [messages, setMessages] = useState([]); const [pendingCount, setPendingCount] = useState(0); const visibleIdsRef = useRef>(new Set()); - const [clerks, setClerks] = useState([]); + const [clerks, setClerks] = useState([]); // Update form state const [updateState, setUpdateState] = useState(''); @@ -64,8 +71,9 @@ export default function RealunitSupportIssueScreen(): JSX.Element { getClerks() .then((list) => { setClerks(list); + if (list.length === 0) setActionError('Clerk list is empty. Assign after the API update is live.'); }) - .catch(() => undefined); + .catch((e: unknown) => setActionError(e instanceof Error ? e.message : 'Failed to load clerks')); }, [getClerks]); const loadIssue = useCallback((): void => { @@ -76,7 +84,9 @@ export default function RealunitSupportIssueScreen(): JSX.Element { setIssueData(data); setUpdateState(data.state); setUpdateDepartment(data.department ?? ''); - setUpdateClerk(data.clerk ?? ''); + setUpdateClerk( + data.clerkUserDataId != null ? String(data.clerkUserDataId) : data.clerk ? LEFTOVER_CLERK_VALUE : '', + ); }) .catch((e: Error) => setLoadError(e.message ?? 'Unknown error')) .finally(() => setIsLoading(false)); @@ -132,7 +142,10 @@ export default function RealunitSupportIssueScreen(): JSX.Element { await updateIssue(+id, { state: updateState || undefined, department: updateDepartment || undefined, - clerk: updateClerk || undefined, + ...clerkAssignmentPayload(updateClerk, issueData?.clerkUserDataId, { + leftover: !!issueData?.clerk, + allowedIds: clerks.map((c) => c.userDataId), + }), }); loadIssue(); } catch (e: unknown) { @@ -294,15 +307,21 @@ export default function RealunitSupportIssueScreen(): JSX.Element { value={updateClerk} onChange={(e) => setUpdateClerk(e.target.value)} > - {!issueData?.clerk && } - {updateClerk && !clerks.includes(updateClerk) && ( - + + {updateClerk === LEFTOVER_CLERK_VALUE && issueData?.clerk && ( + )} + {updateClerk && + Number.isFinite(Number(updateClerk)) && + issueData?.clerk && + !clerks.some((c) => String(c.userDataId) === updateClerk) && ( + + )} {clerks.map((c) => ( - ))} @@ -399,10 +418,7 @@ export default function RealunitSupportIssueScreen(): JSX.Element { className="px-4 py-2 bg-dfxBlue-400 text-white rounded text-sm hover:bg-dfxBlue-800 transition-colors disabled:opacity-50" onClick={() => handleSendMessage()} disabled={ - isSending || - isLoadingAuthor || - !messageAuthor || - (!messageText.trim() && selectedFiles.length === 0) + isSending || isLoadingAuthor || !messageAuthor || (!messageText.trim() && selectedFiles.length === 0) } > {isSending ? '...' : 'Send'} diff --git a/src/screens/support-dashboard-issue.screen.tsx b/src/screens/support-dashboard-issue.screen.tsx index 5a868922a..f08c1b522 100644 --- a/src/screens/support-dashboard-issue.screen.tsx +++ b/src/screens/support-dashboard-issue.screen.tsx @@ -16,6 +16,9 @@ import { useNavigation } from 'src/hooks/navigation.hook'; import { useSplitPane } from 'src/hooks/split-pane.hook'; import { ASSIGNABLE_DEPARTMENTS, + clerkAssignmentPayload, + LEFTOVER_CLERK_VALUE, + SupportClerk, SupportIssueInternalData, SupportMessageInfo, useSupportDashboard, @@ -45,7 +48,7 @@ export default function SupportDashboardIssueScreen(): JSX.Element { const [messages, setMessages] = useState([]); const [pendingCount, setPendingCount] = useState(0); const visibleIdsRef = useRef>(new Set()); - const [clerks, setClerks] = useState([]); + const [clerks, setClerks] = useState([]); // Update form state const [updateState, setUpdateState] = useState(''); @@ -85,8 +88,9 @@ export default function SupportDashboardIssueScreen(): JSX.Element { getClerks() .then((list) => { setClerks(list); + if (list.length === 0) setActionError('Clerk list is empty. Assign after the API update is live.'); }) - .catch(() => undefined); + .catch((e: unknown) => setActionError(e instanceof Error ? e.message : 'Failed to load clerks')); }, [getClerks]); const loadIssue = useCallback((): void => { @@ -97,7 +101,9 @@ export default function SupportDashboardIssueScreen(): JSX.Element { setIssueData(data); setUpdateState(data.state); setUpdateDepartment(data.department ?? ''); - setUpdateClerk(data.clerk ?? ''); + setUpdateClerk( + data.clerkUserDataId != null ? String(data.clerkUserDataId) : data.clerk ? LEFTOVER_CLERK_VALUE : '', + ); }) .catch((e: Error) => setLoadError(e.message ?? 'Unknown error')) .finally(() => setIsLoading(false)); @@ -182,7 +188,10 @@ export default function SupportDashboardIssueScreen(): JSX.Element { await updateIssue(+id, { state: updateState || undefined, department: updateDepartment || undefined, - clerk: updateClerk || undefined, + ...clerkAssignmentPayload(updateClerk, issueData?.clerkUserDataId, { + leftover: !!issueData?.clerk, + allowedIds: clerks.map((c) => c.userDataId), + }), }); loadIssue(); } catch (e: unknown) { @@ -480,15 +489,21 @@ export default function SupportDashboardIssueScreen(): JSX.Element { value={updateClerk} onChange={(e) => setUpdateClerk(e.target.value)} > - {!issueData?.clerk && } - {updateClerk && !clerks.includes(updateClerk) && ( - + + {updateClerk === LEFTOVER_CLERK_VALUE && issueData?.clerk && ( + )} + {updateClerk && + Number.isFinite(Number(updateClerk)) && + issueData?.clerk && + !clerks.some((c) => String(c.userDataId) === updateClerk) && ( + + )} {clerks.map((c) => ( - ))} diff --git a/src/screens/support-dashboard-overview.screen.tsx b/src/screens/support-dashboard-overview.screen.tsx index f882e4531..e9c70e10a 100644 --- a/src/screens/support-dashboard-overview.screen.tsx +++ b/src/screens/support-dashboard-overview.screen.tsx @@ -1,4 +1,4 @@ -import { SupportIssueInternalState, SupportIssueType } from '@dfx.swiss/react'; +import { SupportIssueInternalState, SupportIssueType, useAuthContext } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ErrorHint } from 'src/components/error-hint'; @@ -8,7 +8,7 @@ import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; import { STAFF_NAME_MISSING } from 'src/components/compliance/staff-identity'; import { useStaffVerifiedName } from 'src/hooks/staff-verified-name.hook'; -import { SupportIssueListItem, useSupportDashboard } from 'src/hooks/support-dashboard.hook'; +import { isAssignedToMe, SupportIssueListItem, useSupportDashboard } from 'src/hooks/support-dashboard.hook'; import { formatDateTimeShort } from 'src/util/compliance-helpers'; import { computeStatistics, @@ -46,10 +46,11 @@ export default function SupportDashboardOverviewScreen(): JSX.Element { const { translate, locale } = useSettingsContext(); const { getIssueList, getIssueStatistics } = useSupportDashboard(); const { name: verifiedName, isLoading: isLoadingName, error: nameError } = useStaffVerifiedName(); + const { session } = useAuthContext(); const { navigate } = useNavigation(); - const mineKeys = verifiedName ? [verifiedName] : []; - const mineLoading = isLoadingName; + const canIdentifyMine = session?.account != null || Boolean(verifiedName); + const mineLoading = isLoadingName && session?.account == null; const [issues, setIssues] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -93,8 +94,6 @@ export default function SupportDashboardOverviewScreen(): JSX.Element { return () => clearInterval(id); }, [loadIssues]); - - const scrollToSection = useCallback((id: string): void => { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []); @@ -157,11 +156,9 @@ export default function SupportDashboardOverviewScreen(): JSX.Element { }, [tab, statsPeriod, loadStats]); const stats = useMemo(() => { - const mine = verifiedName - ? issues - .filter((i) => !!i.clerk && i.clerk === verifiedName) - .sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime()) - : []; + const mine = issues + .filter((i) => isAssignedToMe(i, session?.account, verifiedName)) + .sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime()); // tickets where the customer is waiting, sorted by waiting time (longest first) const waitingSorted = issues @@ -178,7 +175,7 @@ export default function SupportDashboardOverviewScreen(): JSX.Element { .sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime()); return { mine, waitingSorted, waitingLongerThan, limitRequests }; - }, [issues, verifiedName, now]); + }, [issues, verifiedName, now, session?.account]); const waitingList = useMemo( () => stats.waitingSorted.filter((x) => x.hours >= waitFilter).map((x) => x.issue), @@ -242,7 +239,7 @@ export default function SupportDashboardOverviewScreen(): JSX.Element { label={translate('screens/support', 'My tickets')} value={ - {mineKeys.length ? stats.mine.length : '–'} + {canIdentifyMine ? stats.mine.length : '–'} / {issues.length} } @@ -313,20 +310,20 @@ export default function SupportDashboardOverviewScreen(): JSX.Element { anchorId="my-tickets" title={translate('screens/support', 'My tickets')} subtitle={translate('screens/support', 'Tickets assigned to me')} - count={mineKeys.length ? stats.mine.length : undefined} + count={canIdentifyMine ? stats.mine.length : undefined} accent="neutral" > {mineLoading ? (
- ) : nameError && !mineKeys.length ? ( + ) : nameError && !canIdentifyMine ? ( - ) : !mineKeys.length ? ( + ) : !canIdentifyMine ? ( ) : stats.mine.length === 0 ? (