Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/admin-web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest';

import type { DisputeRecord } from '@quickwerk/domain';

import { loadDisputeQueueState } from './dispute-queue-actions';
import { loadDisputeQueueState, submitDisputeTransition } from './dispute-queue-actions';
import { createLoadedState } from './dispute-queue-state';

const makeDispute = (id: string): DisputeRecord => ({
disputeId: id,
Expand Down Expand Up @@ -34,6 +35,7 @@ describe('dispute-queue-actions', () => {
if (state.status !== 'loaded') return;
expect(state.disputes).toHaveLength(2);
expect(state.disputes[0]?.disputeId).toBe('d-1');
expect(state.queueAction.status).toBe('idle');
});

it('returns empty state when array is empty', async () => {
Expand Down Expand Up @@ -75,4 +77,67 @@ describe('dispute-queue-actions', () => {
if (state.status !== 'error') return;
expect(state.errorMessage).toBe('Network unavailable');
});

it('applies successful resolve transition and removes terminal dispute from queue', async () => {
const current = createLoadedState([makeDispute('d-1')]);

const fetchImpl = async (_url: string, init?: RequestInit) => {
expect(_url).toBe('http://127.0.0.1:3101/api/v1/disputes/d-1/resolve');
expect(init?.method).toBe('PATCH');
return {
ok: true,
status: 200,
json: async () => ({
...makeDispute('d-1'),
status: 'resolved',
resolvedAt: '2026-01-01T11:00:00.000Z',
resolutionNote: 'Resolved by operator',
}),
} as Response;
};

const next = await submitDisputeTransition(
current,
'token',
'd-1',
'resolve',
{ resolutionNote: 'Resolved by operator' },
fetchImpl as typeof fetch,
);

expect(next.status).toBe('empty');
});

it('rolls back optimistic transition when API call fails', async () => {
const current = createLoadedState([makeDispute('d-1')]);

const fetchImpl = async (_url: string, init?: RequestInit) => {
expect(_url).toBe('http://127.0.0.1:3101/api/v1/disputes/d-1/start-review');
expect(init?.method).toBe('PATCH');
return {
ok: false,
status: 409,
json: async () => ({ message: 'Transition conflict' }),
} as Response;
};

const next = await submitDisputeTransition(
current,
'token',
'd-1',
'startReview',
{},
fetchImpl as typeof fetch,
);

expect(next.status).toBe('loaded');
if (next.status !== 'loaded') return;
expect(next.disputes[0]?.status).toBe('open');
expect(next.queueAction).toEqual({
status: 'error',
disputeId: 'd-1',
actionType: 'startReview',
errorMessage: 'Transition conflict',
});
});
});
94 changes: 91 additions & 3 deletions apps/admin-web/src/features/disputes/dispute-queue-actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { createGetPendingDisputesRequest } from '@quickwerk/api-client';
import {
createCloseDisputeRequest,
createGetPendingDisputesRequest,
createResolveDisputeRequest,
createStartReviewDisputeRequest,
} from '@quickwerk/api-client';
import type { DisputeRecord } from '@quickwerk/domain';

import type { DisputeQueueState } from './dispute-queue-state';
import { createEmptyState, createErrorState, createLoadedState } from './dispute-queue-state';
import type { DisputeQueueActionType, DisputeQueueState } from './dispute-queue-state';
import {
applyDisputeTransitionSuccess,
beginOptimisticDisputeTransition,
createEmptyState,
createErrorState,
createLoadedState,
rollbackDisputeTransition,
} from './dispute-queue-state';

const PLATFORM_API_BASE_URL =
typeof process !== 'undefined'
Expand Down Expand Up @@ -58,3 +70,79 @@ export async function loadDisputeQueueState(
return createErrorState(error instanceof Error ? error.message : 'Unknown error loading dispute queue.');
}
}

type TransitionResult =
| { dispute: DisputeRecord; errorMessage?: undefined }
| { dispute?: undefined; errorMessage: string };

async function requestDisputeTransition(
sessionToken: string,
disputeId: string,
actionType: DisputeQueueActionType,
options: { resolutionNote?: string },
fetchImpl: typeof fetch = fetch,
): Promise<TransitionResult> {
const request =
actionType === 'startReview'
? createStartReviewDisputeRequest(sessionToken, disputeId)
: actionType === 'resolve'
? createResolveDisputeRequest(sessionToken, disputeId, {
resolutionNote: options.resolutionNote ?? '',
})
: createCloseDisputeRequest(sessionToken, disputeId, {
resolutionNote: options.resolutionNote,
});

try {
const response = await fetchImpl(`${PLATFORM_API_BASE_URL}${request.path}`, {
method: request.method,
headers: request.headers,
body: 'body' in request ? JSON.stringify(request.body) : undefined,
});

if (!response.ok) {
const errorPayload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
const message =
typeof errorPayload['message'] === 'string'
? errorPayload['message']
: `Dispute transition failed: HTTP ${response.status}.`;
return { errorMessage: message };
}

const payload = (await response.json()) as unknown;
if (!isDisputeRecord(payload)) {
return { errorMessage: 'Unexpected response format for dispute transition.' };
}

return { dispute: payload };
} catch (error) {
return {
errorMessage: error instanceof Error ? error.message : 'Unknown error during dispute transition.',
};
}
}

export async function submitDisputeTransition(
currentState: DisputeQueueState,
sessionToken: string,
disputeId: string,
actionType: DisputeQueueActionType,
options: { resolutionNote?: string } = {},
fetchImpl: typeof fetch = fetch,
): Promise<DisputeQueueState> {
const optimisticState = beginOptimisticDisputeTransition(currentState, disputeId, actionType);

const result = await requestDisputeTransition(sessionToken, disputeId, actionType, options, fetchImpl);

if (!result.dispute) {
return rollbackDisputeTransition(
optimisticState,
currentState,
disputeId,
actionType,
result.errorMessage ?? 'Dispute transition failed.',
);
}

return applyDisputeTransitionSuccess(optimisticState, result.dispute, actionType);
}
47 changes: 47 additions & 0 deletions apps/admin-web/src/features/disputes/dispute-queue-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { describe, expect, it } from 'vitest';
import type { DisputeRecord } from '@quickwerk/domain';

import {
applyDisputeTransitionSuccess,
beginOptimisticDisputeTransition,
createEmptyState,
createErrorState,
createLoadedState,
createLoadingState,
rollbackDisputeTransition,
} from './dispute-queue-state';

const makeDispute = (id: string): DisputeRecord => ({
Expand Down Expand Up @@ -37,6 +40,7 @@ describe('dispute-queue-state', () => {
if (state.status !== 'loaded') return;
expect(state.disputes).toHaveLength(1);
expect(state.disputes[0]?.disputeId).toBe('d-1');
expect(state.queueAction.status).toBe('idle');
});

it('createErrorState stores error message', () => {
Expand All @@ -45,4 +49,47 @@ describe('dispute-queue-state', () => {
if (state.status !== 'error') return;
expect(state.errorMessage).toBe('Network failed');
});

it('applies optimistic start review transition', () => {
const state = createLoadedState([makeDispute('d-1')]);
const next = beginOptimisticDisputeTransition(state, 'd-1', 'startReview');
expect(next.status).toBe('loaded');
if (next.status !== 'loaded') return;
expect(next.disputes[0]?.status).toBe('under-review');
expect(next.queueAction).toEqual({
status: 'transitioning',
disputeId: 'd-1',
actionType: 'startReview',
});
});

it('removes resolved disputes from queue on success', () => {
const state = createLoadedState([makeDispute('d-1')]);
const next = applyDisputeTransitionSuccess(
state,
{
...makeDispute('d-1'),
status: 'resolved',
resolvedAt: '2026-01-02T10:00:00.000Z',
resolutionNote: 'Resolved',
},
'resolve',
);
expect(next.status).toBe('empty');
});

it('rolls back optimistic state with action error', () => {
const previous = createLoadedState([makeDispute('d-1')]);
const optimistic = beginOptimisticDisputeTransition(previous, 'd-1', 'close');
const rolledBack = rollbackDisputeTransition(optimistic, previous, 'd-1', 'close', 'HTTP 409');
expect(rolledBack.status).toBe('loaded');
if (rolledBack.status !== 'loaded') return;
expect(rolledBack.disputes[0]?.status).toBe('open');
expect(rolledBack.queueAction).toEqual({
status: 'error',
disputeId: 'd-1',
actionType: 'close',
errorMessage: 'HTTP 409',
});
});
});
106 changes: 103 additions & 3 deletions apps/admin-web/src/features/disputes/dispute-queue-state.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,112 @@
import type { DisputeRecord } from '@quickwerk/domain';
import {
disputeOperatorActionTransitions,
type DisputeOperatorActionType,
type DisputeRecord,
} from '@quickwerk/domain';

export type DisputeQueueActionType = DisputeOperatorActionType;

export type DisputeQueueAction =
| { status: 'idle' }
| { status: 'transitioning'; disputeId: string; actionType: DisputeQueueActionType }
| { status: 'done'; disputeId: string; actionType: DisputeQueueActionType }
| { status: 'error'; disputeId: string; actionType: DisputeQueueActionType; errorMessage: string };

export type DisputeQueueState =
| { status: 'loading' }
| { status: 'empty' }
| { status: 'loaded'; disputes: DisputeRecord[] }
| { status: 'loaded'; disputes: DisputeRecord[]; queueAction: DisputeQueueAction }
| { status: 'error'; errorMessage: string };

export const createLoadingState = (): DisputeQueueState => ({ status: 'loading' });
export const createEmptyState = (): DisputeQueueState => ({ status: 'empty' });
export const createLoadedState = (disputes: DisputeRecord[]): DisputeQueueState => ({ status: 'loaded', disputes });
export const createLoadedState = (disputes: DisputeRecord[]): DisputeQueueState => ({
status: 'loaded',
disputes,
queueAction: { status: 'idle' },
});
export const createErrorState = (errorMessage: string): DisputeQueueState => ({ status: 'error', errorMessage });

export const beginOptimisticDisputeTransition = (
state: DisputeQueueState,
disputeId: string,
actionType: DisputeQueueActionType,
): DisputeQueueState => {
if (state.status !== 'loaded') {
return state;
}

if (state.queueAction?.status === 'transitioning') {
return state;
}

const dispute = state.disputes.find((d) => d.disputeId === disputeId);
if (!dispute) {
return state;
}

const nextStatus = disputeOperatorActionTransitions[actionType];
if (!nextStatus || nextStatus === dispute.status) {
return state;
}

const disputes = state.disputes.map((d) => {
if (d.disputeId !== disputeId) {
return d;
}

return { ...d, status: nextStatus };
});

return {
...state,
disputes,
queueAction: { status: 'transitioning', disputeId, actionType },
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export const applyDisputeTransitionSuccess = (
state: DisputeQueueState,
dispute: DisputeRecord,
actionType: DisputeQueueActionType,
): DisputeQueueState => {
if (state.status !== 'loaded') {
return state;
}

if (dispute.status === 'resolved' || dispute.status === 'closed') {
const remaining = state.disputes.filter((item) => item.disputeId !== dispute.disputeId);
if (remaining.length === 0) {
return createEmptyState();
}

return {
status: 'loaded',
disputes: remaining,
queueAction: { status: 'done', disputeId: dispute.disputeId, actionType },
};
}

return {
status: 'loaded',
disputes: state.disputes.map((item) => (item.disputeId === dispute.disputeId ? dispute : item)),
queueAction: { status: 'done', disputeId: dispute.disputeId, actionType },
};
};

export const rollbackDisputeTransition = (
state: DisputeQueueState,
previousState: DisputeQueueState,
disputeId: string,
actionType: DisputeQueueActionType,
errorMessage: string,
): DisputeQueueState => {
if (previousState.status !== 'loaded') {
return createErrorState(errorMessage);
}

return {
...previousState,
queueAction: { status: 'error', disputeId, actionType, errorMessage },
};
};
6 changes: 2 additions & 4 deletions apps/admin-web/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src"
},
"compilerOptions": {},
"include": ["src/**/*.ts", ".next/types/**/*.ts"]
}
}
Loading