diff --git a/apps/admin-web/next-env.d.ts b/apps/admin-web/next-env.d.ts
index 1b3be08..7cc6ac7 100644
--- a/apps/admin-web/next-env.d.ts
+++ b/apps/admin-web/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+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.
\ No newline at end of file
diff --git a/apps/admin-web/src/features/disputes/dispute-queue-actions.test.ts b/apps/admin-web/src/features/disputes/dispute-queue-actions.test.ts
index fb55245..2180254 100644
--- a/apps/admin-web/src/features/disputes/dispute-queue-actions.test.ts
+++ b/apps/admin-web/src/features/disputes/dispute-queue-actions.test.ts
@@ -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,
@@ -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 () => {
@@ -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',
+ });
+ });
});
diff --git a/apps/admin-web/src/features/disputes/dispute-queue-actions.ts b/apps/admin-web/src/features/disputes/dispute-queue-actions.ts
index 24db18b..ec4eba8 100644
--- a/apps/admin-web/src/features/disputes/dispute-queue-actions.ts
+++ b/apps/admin-web/src/features/disputes/dispute-queue-actions.ts
@@ -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'
@@ -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 {
+ 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;
+ 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 {
+ 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);
+}
diff --git a/apps/admin-web/src/features/disputes/dispute-queue-state.test.ts b/apps/admin-web/src/features/disputes/dispute-queue-state.test.ts
index 6a35792..90e645b 100644
--- a/apps/admin-web/src/features/disputes/dispute-queue-state.test.ts
+++ b/apps/admin-web/src/features/disputes/dispute-queue-state.test.ts
@@ -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 => ({
@@ -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', () => {
@@ -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',
+ });
+ });
});
diff --git a/apps/admin-web/src/features/disputes/dispute-queue-state.ts b/apps/admin-web/src/features/disputes/dispute-queue-state.ts
index f2bb5a3..112f9b7 100644
--- a/apps/admin-web/src/features/disputes/dispute-queue-state.ts
+++ b/apps/admin-web/src/features/disputes/dispute-queue-state.ts
@@ -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 },
+ };
+};
+
+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 },
+ };
+};
\ No newline at end of file
diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json
index 9350180..4b3e2e7 100644
--- a/apps/admin-web/tsconfig.json
+++ b/apps/admin-web/tsconfig.json
@@ -1,7 +1,5 @@
{
"extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "rootDir": "src"
- },
+ "compilerOptions": {},
"include": ["src/**/*.ts", ".next/types/**/*.ts"]
-}
+}
\ No newline at end of file
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 0bceecc..60105ce 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -316,6 +316,9 @@ export const createGetBookingInvoiceRequest = (sessionToken: string, bookingId:
export const disputeApiRoutes = {
submit: (bookingId: string) => `/api/v1/bookings/${bookingId}/dispute`,
pending: () => '/api/v1/disputes/pending',
+ startReview: (disputeId: string) => `/api/v1/disputes/${disputeId}/start-review`,
+ resolve: (disputeId: string) => `/api/v1/disputes/${disputeId}/resolve`,
+ close: (disputeId: string) => `/api/v1/disputes/${disputeId}/close`,
} as const;
export const createSubmitDisputeRequest = (
@@ -335,6 +338,34 @@ export const createGetPendingDisputesRequest = (sessionToken: string) => ({
headers: { authorization: `Bearer ${sessionToken}` },
}) as const;
+export const createStartReviewDisputeRequest = (sessionToken: string, disputeId: string) => ({
+ method: 'PATCH' as const,
+ path: disputeApiRoutes.startReview(disputeId),
+ headers: { authorization: `Bearer ${sessionToken}` },
+}) as const;
+
+export const createResolveDisputeRequest = (
+ sessionToken: string,
+ disputeId: string,
+ body: { resolutionNote?: string },
+) => ({
+ method: 'PATCH' as const,
+ path: disputeApiRoutes.resolve(disputeId),
+ headers: { authorization: `Bearer ${sessionToken}`, 'content-type': 'application/json' },
+ body,
+}) as const;
+
+export const createCloseDisputeRequest = (
+ sessionToken: string,
+ disputeId: string,
+ body?: { resolutionNote?: string },
+) => ({
+ method: 'PATCH' as const,
+ path: disputeApiRoutes.close(disputeId),
+ headers: { authorization: `Bearer ${sessionToken}`, 'content-type': 'application/json' },
+ body,
+}) as const;
+
// --- Reviews ---
export const bookingReviewApiRoutes = {
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index 63ffb68..7e5326e 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -290,3 +290,38 @@ export type DisputeSubmittedDomainEvent = {
correlationId: string;
occurredAt: string;
};
+
+export type DisputeOperatorActionType = 'startReview' | 'resolve' | 'close';
+
+export type StartDisputeReviewAction = {
+ type: 'startReview';
+};
+
+export type ResolveDisputeAction = {
+ type: 'resolve';
+ resolutionNote: string;
+};
+
+export type CloseDisputeAction = {
+ type: 'close';
+ resolutionNote?: string;
+};
+
+export type DisputeOperatorAction = StartDisputeReviewAction | ResolveDisputeAction | CloseDisputeAction;
+
+export const disputeOperatorActionTransitions: Record = {
+ startReview: 'under-review',
+ resolve: 'resolved',
+ close: 'closed',
+};
+
+const disputeOperatorActionAllowedFromStatuses: Record = {
+ startReview: ['open'],
+ resolve: ['under-review'],
+ close: ['under-review'],
+};
+
+export const canApplyDisputeOperatorAction = (
+ currentStatus: DisputeStatus,
+ action: DisputeOperatorActionType,
+): boolean => disputeOperatorActionAllowedFromStatuses[action].includes(currentStatus);
diff --git a/scripts/smoke/operator-dispute-flow-smoke.sh b/scripts/smoke/operator-dispute-flow-smoke.sh
new file mode 100755
index 0000000..155a24c
--- /dev/null
+++ b/scripts/smoke/operator-dispute-flow-smoke.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+# Smoke test: dispute submit -> operator start-review -> resolve -> pending list terminal check
+set -euo pipefail
+
+BASE_URL="${QW_PLATFORM_API_BASE_URL:-http://127.0.0.1:3101}"
+
+req() {
+ local method="$1"
+ local path="$2"
+ local token="${3:-}"
+ local body="${4:-}"
+
+ if [[ -n "$token" ]]; then
+ if [[ -n "$body" ]]; then
+ curl --fail --silent --show-error \
+ -X "$method" \
+ -H "Authorization: Bearer $token" \
+ -H "Content-Type: application/json" \
+ -d "$body" \
+ "$BASE_URL$path"
+ else
+ curl --fail --silent --show-error \
+ -X "$method" \
+ -H "Authorization: Bearer $token" \
+ "$BASE_URL$path"
+ fi
+ else
+ if [[ -n "$body" ]]; then
+ curl --fail --silent --show-error \
+ -X "$method" \
+ -H "Content-Type: application/json" \
+ -d "$body" \
+ "$BASE_URL$path"
+ else
+ curl --fail --silent --show-error -X "$method" "$BASE_URL$path"
+ fi
+ fi
+}
+
+echo "[smoke] === Operator Dispute Flow ==="
+
+echo "[smoke] Sign in as customer"
+CUSTOMER_SIGNIN=$(req POST /api/v1/auth/sign-in "" '{"email":"customer.dispute.smoke@quickwerk.local","role":"customer"}')
+CUSTOMER_TOKEN=$(echo "$CUSTOMER_SIGNIN" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+if [[ -z "$CUSTOMER_TOKEN" ]]; then
+ echo "[smoke] ERROR: customer token missing" >&2
+ exit 1
+fi
+
+echo "[smoke] Sign in as provider"
+PROVIDER_SIGNIN=$(req POST /api/v1/auth/sign-in "" '{"email":"provider.dispute.smoke@quickwerk.local","role":"provider"}')
+PROVIDER_TOKEN=$(echo "$PROVIDER_SIGNIN" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+PROVIDER_USER_ID=$(echo "$PROVIDER_SIGNIN" | sed -n 's/.*"userId":"\([^"]*\)".*/\1/p')
+
+if [[ -z "$PROVIDER_TOKEN" || -z "$PROVIDER_USER_ID" ]]; then
+ echo "[smoke] ERROR: provider auth/session data missing" >&2
+ exit 1
+fi
+
+echo "[smoke] Create booking as customer"
+BOOKING_RESPONSE=$(req POST /api/v1/bookings "$CUSTOMER_TOKEN" '{"requestedService":"Dispute smoke booking"}')
+BOOKING_ID=$(echo "$BOOKING_RESPONSE" | sed -n 's/.*"bookingId":"\([^"]*\)".*/\1/p')
+
+if [[ -z "$BOOKING_ID" ]]; then
+ echo "[smoke] ERROR: booking id missing" >&2
+ exit 1
+fi
+
+echo "[smoke] Provider accepts booking"
+ACCEPT_RESPONSE=$(req POST "/api/v1/bookings/$BOOKING_ID/accept" "$PROVIDER_TOKEN")
+echo "$ACCEPT_RESPONSE" | grep -q '"status":"accepted"' || {
+ echo "[smoke] ERROR: booking should be accepted" >&2
+ exit 1
+}
+
+echo "[smoke] Provider completes booking"
+COMPLETE_RESPONSE=$(req POST "/api/v1/bookings/$BOOKING_ID/complete" "$PROVIDER_TOKEN" "{\"providerUserId\":\"$PROVIDER_USER_ID\"}")
+echo "$COMPLETE_RESPONSE" | grep -q '"status":"completed"' || {
+ echo "[smoke] ERROR: booking should be completed" >&2
+ exit 1
+}
+
+echo "[smoke] Customer submits dispute"
+SUBMIT_DISPUTE_RESPONSE=$(req POST "/api/v1/bookings/$BOOKING_ID/dispute" "$CUSTOMER_TOKEN" '{"category":"quality","description":"Work quality was unacceptable"}')
+DISPUTE_ID=$(echo "$SUBMIT_DISPUTE_RESPONSE" | sed -n 's/.*"disputeId":"\([^"]*\)".*/\1/p')
+
+if [[ -z "$DISPUTE_ID" ]]; then
+ echo "[smoke] ERROR: dispute id missing" >&2
+ exit 1
+fi
+
+echo "$SUBMIT_DISPUTE_RESPONSE" | grep -q '"status":"open"' || {
+ echo "[smoke] ERROR: dispute should start open" >&2
+ exit 1
+}
+
+echo "[smoke] Sign in as operator"
+OPERATOR_SIGNIN=$(req POST /api/v1/auth/sign-in "" '{"email":"operator.dispute.smoke@quickwerk.local","role":"operator"}')
+OPERATOR_TOKEN=$(echo "$OPERATOR_SIGNIN" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+if [[ -z "$OPERATOR_TOKEN" ]]; then
+ echo "[smoke] ERROR: operator token missing" >&2
+ exit 1
+fi
+
+echo "[smoke] Operator starts dispute review"
+START_REVIEW_RESPONSE=$(req PATCH "/api/v1/disputes/$DISPUTE_ID/start-review" "$OPERATOR_TOKEN")
+echo "$START_REVIEW_RESPONSE" | grep -q '"status":"under-review"' || {
+ echo "[smoke] ERROR: dispute should be under-review" >&2
+ exit 1
+}
+
+echo "[smoke] Operator resolves dispute"
+RESOLVE_RESPONSE=$(req PATCH "/api/v1/disputes/$DISPUTE_ID/resolve" "$OPERATOR_TOKEN" '{"resolutionNote":"Refund issued to customer"}')
+echo "$RESOLVE_RESPONSE" | grep -q '"status":"resolved"' || {
+ echo "[smoke] ERROR: dispute should be resolved" >&2
+ exit 1
+}
+
+echo "[smoke] Pending queue should no longer include resolved dispute"
+PENDING_AFTER=$(req GET /api/v1/disputes/pending "$OPERATOR_TOKEN")
+echo "$PENDING_AFTER" | grep -q "$DISPUTE_ID" && {
+ echo "[smoke] ERROR: resolved dispute should not remain in pending queue" >&2
+ exit 1
+} || true
+
+echo "[smoke] === Flow passed: submit -> start-review -> resolve -> terminal queue removal ==="
diff --git a/services/platform-api/migrations/0007_disputes_lifecycle.sql b/services/platform-api/migrations/0007_disputes_lifecycle.sql
new file mode 100644
index 0000000..7ea5b14
--- /dev/null
+++ b/services/platform-api/migrations/0007_disputes_lifecycle.sql
@@ -0,0 +1,27 @@
+-- Add disputes lifecycle persistence for operator dispute resolution.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS disputes (
+ id UUID PRIMARY KEY,
+ booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
+ reporter_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ reporter_role TEXT NOT NULL CHECK (reporter_role IN ('customer', 'provider')),
+ category TEXT NOT NULL CHECK (category IN ('no-show', 'quality', 'billing', 'safety', 'other')),
+ description TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('open', 'under-review', 'resolved', 'closed')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ resolved_at TIMESTAMPTZ,
+ resolution_note TEXT,
+ CONSTRAINT disputes_unique_booking_reporter UNIQUE (booking_id, reporter_user_id),
+ CONSTRAINT disputes_resolution_fields_consistency CHECK (
+ (status IN ('resolved', 'closed') AND resolved_at IS NOT NULL)
+ OR (status IN ('open', 'under-review') AND resolved_at IS NULL)
+ )
+);
+
+CREATE INDEX IF NOT EXISTS idx_disputes_status_created_at ON disputes(status, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_disputes_reporter_user_id_created_at ON disputes(reporter_user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_disputes_booking_id_created_at ON disputes(booking_id, created_at DESC);
+
+COMMIT;
diff --git a/services/platform-api/migrations/README.md b/services/platform-api/migrations/README.md
index eebfd8e..5a86da0 100644
--- a/services/platform-api/migrations/README.md
+++ b/services/platform-api/migrations/README.md
@@ -23,6 +23,13 @@ This folder contains plain SQL migration scaffolding for the upcoming PostgreSQL
- `0005_operator_role_support.sql`
- expands `users.role` constraint to include dedicated `operator`
- preserves existing `customer`/`provider` rows while enabling operator session migration
+- `0006_booking_declined_support.sql`
+ - adds `declined` status support to bookings and booking status history
+ - adds nullable `decline_reason` with backward-safe constraint widening
+- `0007_disputes_lifecycle.sql`
+ - creates durable `disputes` table with lifecycle states (`open`, `under-review`, `resolved`, `closed`)
+ - enforces one dispute per (`booking_id`, `reporter_user_id`) and resolution field consistency checks
+ - adds indexes for operator queue and reporter/booking lookups
## How to run later (manual)
@@ -34,6 +41,8 @@ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f services/platform-api/migrations/0002
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f services/platform-api/migrations/0003_booking_accepted_relay_attempts.sql
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f services/platform-api/migrations/0004_booking_accepted_relay_queue_snapshots.sql
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f services/platform-api/migrations/0005_operator_role_support.sql
+psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f services/platform-api/migrations/0006_booking_declined_support.sql
+psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f services/platform-api/migrations/0007_disputes_lifecycle.sql
```
Rollback is currently manual (early scaffold phase).
diff --git a/services/platform-api/src/disputes/disputes.controller.ts b/services/platform-api/src/disputes/disputes.controller.ts
index ea441c0..3f1b3d3 100644
--- a/services/platform-api/src/disputes/disputes.controller.ts
+++ b/services/platform-api/src/disputes/disputes.controller.ts
@@ -1,4 +1,4 @@
-import { Body, Controller, Get, Headers, HttpException, Param, Post, Req, Res } from '@nestjs/common';
+import { Body, Controller, Get, Headers, HttpCode, HttpException, Param, Patch, Post, Req, Res } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { extractBearerToken } from '../http/auth-header';
@@ -20,6 +20,14 @@ type SubmitDisputeBody = {
description: string;
};
+type ResolveDisputeBody = {
+ resolutionNote: unknown;
+};
+
+type CloseDisputeBody = {
+ resolutionNote?: unknown;
+};
+
@Controller('api/v1/bookings')
export class DisputesBookingController {
constructor(
@@ -114,4 +122,102 @@ export class DisputesOperatorController {
return result.disputes;
}
-}
+
+ @Patch(':disputeId/start-review')
+ @HttpCode(200)
+ async startReviewDispute(
+ @Req() request: RequestLike,
+ @Res({ passthrough: true }) response: ResponseLike,
+ @Headers('authorization') authorizationHeader: string | undefined,
+ @Param('disputeId') disputeId: string,
+ ) {
+ const token = extractBearerToken(authorizationHeader);
+ const correlationId = resolveCorrelationId({
+ headerValue: request.header(correlationIdHeaderName) ?? undefined,
+ method: request.method,
+ path: request.path,
+ token,
+ body: {},
+ });
+
+ response.setHeader(correlationIdHeaderName, correlationId);
+
+ const session = await this.authService.resolveSessionOrNull(token);
+ if (!session) {
+ throw new HttpException('Sign-in required to review disputes.', 401);
+ }
+
+ const result = await this.disputesService.startReviewDispute(session, disputeId, correlationId);
+ if (!result.ok) {
+ throw new HttpException(result.error, result.statusCode);
+ }
+
+ return result.dispute;
+ }
+
+ @Patch(':disputeId/resolve')
+ @HttpCode(200)
+ async resolveDispute(
+ @Req() request: RequestLike,
+ @Res({ passthrough: true }) response: ResponseLike,
+ @Headers('authorization') authorizationHeader: string | undefined,
+ @Param('disputeId') disputeId: string,
+ @Body() body: ResolveDisputeBody,
+ ) {
+ const token = extractBearerToken(authorizationHeader);
+ const correlationId = resolveCorrelationId({
+ headerValue: request.header(correlationIdHeaderName) ?? undefined,
+ method: request.method,
+ path: request.path,
+ token,
+ body,
+ });
+
+ response.setHeader(correlationIdHeaderName, correlationId);
+
+ const session = await this.authService.resolveSessionOrNull(token);
+ if (!session) {
+ throw new HttpException('Sign-in required to review disputes.', 401);
+ }
+
+ const result = await this.disputesService.resolveDispute(session, disputeId, body.resolutionNote, correlationId);
+ if (!result.ok) {
+ throw new HttpException(result.error, result.statusCode);
+ }
+
+ return result.dispute;
+ }
+
+ @Patch(':disputeId/close')
+ @HttpCode(200)
+ async closeDispute(
+ @Req() request: RequestLike,
+ @Res({ passthrough: true }) response: ResponseLike,
+ @Headers('authorization') authorizationHeader: string | undefined,
+ @Param('disputeId') disputeId: string,
+ @Body() body: CloseDisputeBody,
+ ) {
+ const token = extractBearerToken(authorizationHeader);
+ const correlationId = resolveCorrelationId({
+ headerValue: request.header(correlationIdHeaderName) ?? undefined,
+ method: request.method,
+ path: request.path,
+ token,
+ body,
+ });
+
+ response.setHeader(correlationIdHeaderName, correlationId);
+
+ const session = await this.authService.resolveSessionOrNull(token);
+ if (!session) {
+ throw new HttpException('Sign-in required to review disputes.', 401);
+ }
+
+ const result = await this.disputesService.closeDispute(session, disputeId, body?.resolutionNote, correlationId);
+ if (!result.ok) {
+ throw new HttpException(result.error, result.statusCode);
+ }
+
+ return result.dispute;
+ }
+}
\ No newline at end of file
diff --git a/services/platform-api/src/disputes/disputes.module.ts b/services/platform-api/src/disputes/disputes.module.ts
index 64b5995..e1d1f2e 100644
--- a/services/platform-api/src/disputes/disputes.module.ts
+++ b/services/platform-api/src/disputes/disputes.module.ts
@@ -2,15 +2,29 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BookingsModule } from '../bookings/bookings.module';
+import { PostgresClient } from '../persistence/postgres-client';
+import { DISPUTE_REPOSITORY } from './domain/dispute.repository';
import { InMemoryDisputeRepository } from './infrastructure/in-memory-dispute.repository';
-import { disputeRepositoryProvider } from './infrastructure/dispute-repository.provider';
+import { resolveDisputeRepository } from './infrastructure/dispute-repository.provider';
import { DisputesBookingController, DisputesOperatorController } from './disputes.controller';
import { DisputesService } from './disputes.service';
@Module({
imports: [AuthModule, BookingsModule],
controllers: [DisputesBookingController, DisputesOperatorController],
- providers: [DisputesService, InMemoryDisputeRepository, disputeRepositoryProvider],
+ providers: [
+ DisputesService,
+ InMemoryDisputeRepository,
+ {
+ provide: DISPUTE_REPOSITORY,
+ inject: [InMemoryDisputeRepository, PostgresClient],
+ useFactory: (inMemoryRepository: InMemoryDisputeRepository, postgresClient: PostgresClient) =>
+ resolveDisputeRepository({
+ inMemoryRepository,
+ postgresClient,
+ }),
+ },
+ ],
exports: [DisputesService],
})
export class DisputesModule {}
diff --git a/services/platform-api/src/disputes/disputes.service.test.ts b/services/platform-api/src/disputes/disputes.service.test.ts
index ff036cd..d5d0aea 100644
--- a/services/platform-api/src/disputes/disputes.service.test.ts
+++ b/services/platform-api/src/disputes/disputes.service.test.ts
@@ -154,13 +154,22 @@ describe('DisputesService', () => {
});
describe('getPendingDisputes', () => {
- it('returns open disputes for operator session', async () => {
+ it('returns open and under-review disputes for operator session', async () => {
const { service, bookings } = createService();
const customer = makeCustomerSession();
const operator = makeOperatorSession();
const booking = await createCompletedBooking(bookings, customer.userId, 'provider-1');
- await service.submitDispute(customer, booking.bookingId, 'safety', 'Safety concern.', 'corr-4');
+ const submitted = await service.submitDispute(customer, booking.bookingId, 'safety', 'Safety concern.', 'corr-4');
+ if (!submitted.ok) {
+ throw new Error('Expected dispute submit to succeed in setup.');
+ }
+
+ const review = await service.startReviewDispute(operator, submitted.dispute.disputeId, 'corr-4a');
+ expect(review.ok).toBe(true);
+ if (!review.ok) {
+ throw new Error('Expected review transition to succeed in setup.');
+ }
const result = await service.getPendingDisputes(operator);
@@ -168,7 +177,8 @@ describe('DisputesService', () => {
if (result.ok) {
expect(Array.isArray(result.disputes)).toBe(true);
expect(result.disputes.length).toBeGreaterThan(0);
- expect(result.disputes.every((d) => d.status === 'open')).toBe(true);
+ expect(result.disputes.every((d) => d.status === 'open' || d.status === 'under-review')).toBe(true);
+ expect(result.disputes.some((d) => d.status === 'under-review')).toBe(true);
}
});
@@ -196,4 +206,152 @@ describe('DisputesService', () => {
}
});
});
+
+ describe('operator transitions', () => {
+ it('supports open -> under-review -> resolved and removes from pending list', async () => {
+ const { service, bookings } = createService();
+ const customer = makeCustomerSession();
+ const operator = makeOperatorSession();
+ const booking = await createCompletedBooking(bookings, customer.userId, 'provider-1');
+
+ const submitted = await service.submitDispute(customer, booking.bookingId, 'quality', 'Need review', 'corr-5');
+ expect(submitted.ok).toBe(true);
+ if (!submitted.ok) return;
+
+ const review = await service.startReviewDispute(operator, submitted.dispute.disputeId, 'corr-5a');
+ expect(review.ok).toBe(true);
+ if (!review.ok) return;
+ expect(review.dispute.status).toBe('under-review');
+
+ const resolved = await service.resolveDispute(
+ operator,
+ submitted.dispute.disputeId,
+ 'Refund approved for customer.',
+ 'corr-5b',
+ );
+ expect(resolved.ok).toBe(true);
+ if (!resolved.ok) return;
+ expect(resolved.dispute.status).toBe('resolved');
+ expect(resolved.dispute.resolvedAt).not.toBeNull();
+ expect(resolved.dispute.resolutionNote).toContain('Refund');
+
+ const pending = await service.getPendingDisputes(operator);
+ expect(pending.ok).toBe(true);
+ if (!pending.ok) return;
+ expect(pending.disputes.some((d) => d.disputeId === submitted.dispute.disputeId)).toBe(false);
+ });
+
+ it('enforces operator role and transition guards', async () => {
+ const { service, bookings } = createService();
+ const customer = makeCustomerSession();
+ const booking = await createCompletedBooking(bookings, customer.userId, 'provider-1');
+
+ const submitted = await service.submitDispute(customer, booking.bookingId, 'billing', 'Charge mismatch', 'corr-6');
+ expect(submitted.ok).toBe(true);
+ if (!submitted.ok) return;
+
+ const forbidden = await service.startReviewDispute(customer, submitted.dispute.disputeId, 'corr-6a');
+ expect(forbidden.ok).toBe(false);
+ if (!forbidden.ok) {
+ expect(forbidden.statusCode).toBe(403);
+ }
+
+ const operator = makeOperatorSession();
+ const invalidResolve = await service.resolveDispute(
+ operator,
+ submitted.dispute.disputeId,
+ 'Cannot resolve directly from open.',
+ 'corr-6b',
+ );
+ expect(invalidResolve.ok).toBe(false);
+ if (!invalidResolve.ok) {
+ expect(invalidResolve.statusCode).toBe(409);
+ }
+ });
+
+ it('rejects invalid resolution note payloads', async () => {
+ const { service, bookings } = createService();
+ const customer = makeCustomerSession();
+ const operator = makeOperatorSession();
+ const booking = await createCompletedBooking(bookings, customer.userId, 'provider-1');
+
+ const submitted = await service.submitDispute(customer, booking.bookingId, 'billing', 'Bad payload', 'corr-6c');
+ expect(submitted.ok).toBe(true);
+ if (!submitted.ok) return;
+
+ const review = await service.startReviewDispute(operator, submitted.dispute.disputeId, 'corr-6d');
+ expect(review.ok).toBe(true);
+ if (!review.ok) return;
+
+ const invalidResolve = await service.resolveDispute(
+ operator,
+ submitted.dispute.disputeId,
+ 1234,
+ 'corr-6e',
+ );
+ expect(invalidResolve.ok).toBe(false);
+ if (!invalidResolve.ok) {
+ expect(invalidResolve.statusCode).toBe(400);
+ }
+
+ const invalidClose = await service.closeDispute(operator, submitted.dispute.disputeId, { note: 'x' }, 'corr-6f');
+ expect(invalidClose.ok).toBe(false);
+ if (!invalidClose.ok) {
+ expect(invalidClose.statusCode).toBe(400);
+ }
+ });
+
+ it('is idempotent for repeated operator actions on already transitioned records', async () => {
+ const { service, bookings } = createService();
+ const customer = makeCustomerSession();
+ const operator = makeOperatorSession();
+ const booking = await createCompletedBooking(bookings, customer.userId, 'provider-1');
+
+ const submitted = await service.submitDispute(customer, booking.bookingId, 'other', 'Duplicate retry', 'corr-7');
+ expect(submitted.ok).toBe(true);
+ if (!submitted.ok) return;
+
+ const first = await service.startReviewDispute(operator, submitted.dispute.disputeId, 'corr-7a');
+ const second = await service.startReviewDispute(operator, submitted.dispute.disputeId, 'corr-7b');
+
+ expect(first.ok).toBe(true);
+ expect(second.ok).toBe(true);
+ if (!first.ok || !second.ok) return;
+ expect(first.dispute.status).toBe('under-review');
+ expect(second.dispute.status).toBe('under-review');
+ });
+
+ it('supports close after review while enforcing operator-only access', async () => {
+ const { service, bookings } = createService();
+ const customer = makeCustomerSession();
+ const operator = makeOperatorSession();
+ const booking = await createCompletedBooking(bookings, customer.userId, 'provider-1');
+
+ const submitted = await service.submitDispute(customer, booking.bookingId, 'quality', 'Close flow', 'corr-8');
+ expect(submitted.ok).toBe(true);
+ if (!submitted.ok) return;
+
+ const review = await service.startReviewDispute(operator, submitted.dispute.disputeId, 'corr-8a');
+ expect(review.ok).toBe(true);
+ if (!review.ok) return;
+
+ const forbiddenClose = await service.closeDispute(customer, submitted.dispute.disputeId, 'not allowed', 'corr-8b');
+ expect(forbiddenClose.ok).toBe(false);
+ if (!forbiddenClose.ok) {
+ expect(forbiddenClose.statusCode).toBe(403);
+ }
+
+ const closed = await service.closeDispute(operator, submitted.dispute.disputeId, 'Closed after review.', 'corr-8c');
+ expect(closed.ok).toBe(true);
+ if (!closed.ok) return;
+ expect(closed.dispute.status).toBe('closed');
+ expect(closed.dispute.resolvedAt).not.toBeNull();
+ expect(closed.dispute.resolutionNote).toContain('Closed after review');
+
+ const pending = await service.getPendingDisputes(operator);
+ expect(pending.ok).toBe(true);
+ if (!pending.ok) return;
+ expect(pending.disputes.some((d) => d.disputeId === submitted.dispute.disputeId)).toBe(false);
+ });
+ });
});
diff --git a/services/platform-api/src/disputes/disputes.service.ts b/services/platform-api/src/disputes/disputes.service.ts
index 3c2924b..6ff5c52 100644
--- a/services/platform-api/src/disputes/disputes.service.ts
+++ b/services/platform-api/src/disputes/disputes.service.ts
@@ -1,7 +1,13 @@
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
-import type { DisputeRecord, DisputeSubmittedDomainEvent } from '@quickwerk/domain';
+import {
+ canApplyDisputeOperatorAction,
+ disputeOperatorActionTransitions,
+ type DisputeOperatorActionType,
+ type DisputeRecord,
+ type DisputeSubmittedDomainEvent,
+} from '@quickwerk/domain';
import { AuthSession } from '../auth/domain/auth-session.repository';
import { BOOKING_REPOSITORY, BookingRepository } from '../bookings/domain/booking.repository';
@@ -145,7 +151,101 @@ export class DisputesService {
return { ok: false, statusCode: 403, error: 'Only operators can view pending disputes.' };
}
- const disputes = await this.disputes.findByStatus('open');
+ const disputes = await this.disputes.findByStatuses(['open', 'under-review']);
+
return { ok: true, disputes };
}
+
+ async startReviewDispute(
+ session: AuthSession,
+ disputeId: string,
+ correlationId: string,
+ ): Promise<{ ok: true; dispute: DisputeRecord } | { ok: false; statusCode: 403 | 404 | 409; error: string }> {
+ return this.transitionDispute(session, disputeId, 'startReview', null, correlationId);
+ }
+
+ async resolveDispute(
+ session: AuthSession,
+ disputeId: string,
+ resolutionNote: unknown,
+ correlationId: string,
+ ): Promise<{ ok: true; dispute: DisputeRecord } | { ok: false; statusCode: 400 | 403 | 404 | 409; error: string }> {
+ if (typeof resolutionNote !== 'string') {
+ return { ok: false, statusCode: 400, error: 'resolutionNote must be a string and non-empty.' };
+ }
+
+ const note = resolutionNote.trim();
+ if (!note) {
+ return { ok: false, statusCode: 400, error: 'resolutionNote is required to resolve a dispute.' };
+ }
+
+ return this.transitionDispute(session, disputeId, 'resolve', note, correlationId);
+ }
+
+ async closeDispute(
+ session: AuthSession,
+ disputeId: string,
+ resolutionNote: unknown,
+ correlationId: string,
+ ): Promise<{ ok: true; dispute: DisputeRecord } | { ok: false; statusCode: 400 | 403 | 404 | 409; error: string }> {
+ if (resolutionNote !== undefined && resolutionNote !== null && typeof resolutionNote !== 'string') {
+ return { ok: false, statusCode: 400, error: 'resolutionNote must be a string when provided.' };
+ }
+
+ const note = typeof resolutionNote === 'string' ? resolutionNote.trim() : null;
+ return this.transitionDispute(session, disputeId, 'close', note && note.length > 0 ? note : null, correlationId);
+ }
+
+ private async transitionDispute(
+ session: AuthSession,
+ disputeId: string,
+ action: DisputeOperatorActionType,
+ resolutionNote: string | null,
+ correlationId: string,
+ ): Promise<{ ok: true; dispute: DisputeRecord } | { ok: false; statusCode: 403 | 404 | 409; error: string }> {
+ if (session.role !== 'operator') {
+ return { ok: false, statusCode: 403, error: 'Only operators can transition disputes.' };
+ }
+
+ const targetStatus = disputeOperatorActionTransitions[action];
+
+ const allowedCurrentStatuses = ['open', 'under-review', 'resolved', 'closed'].filter((status) =>
+ canApplyDisputeOperatorAction(status as DisputeRecord['status'], action),
+ ) as DisputeRecord['status'][];
+
+ const transitionResult = await this.disputes.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses,
+ nextStatus: targetStatus,
+ resolvedAt: action === 'startReview' ? undefined : new Date().toISOString(),
+ resolutionNote: action === 'startReview' ? undefined : resolutionNote,
+ });
+
+ if (!transitionResult.ok) {
+ if (transitionResult.reason === 'not-found') {
+ return { ok: false, statusCode: 404, error: 'Dispute not found.' };
+ }
+
+ return {
+ ok: false,
+ statusCode: 409,
+ error: `Dispute cannot transition from ${transitionResult.currentStatus} via ${action}.`,
+ };
+ }
+
+ logStructuredBreadcrumb({
+ event: 'dispute.operator.transition',
+ correlationId,
+ status: 'succeeded',
+ details: {
+ disputeId,
+ action,
+ actorUserId: session.userId,
+ replayed: transitionResult.replayed,
+ nextStatus: transitionResult.dispute.status,
+ },
+ });
+
+ return { ok: true, dispute: transitionResult.dispute };
+ }
}
diff --git a/services/platform-api/src/disputes/domain/dispute.repository.ts b/services/platform-api/src/disputes/domain/dispute.repository.ts
index 670b56d..c39d8f7 100644
--- a/services/platform-api/src/disputes/domain/dispute.repository.ts
+++ b/services/platform-api/src/disputes/domain/dispute.repository.ts
@@ -11,9 +11,22 @@ export type CreateDisputeInput = {
export interface DisputeRepository {
save(dispute: DisputeRecord): Promise<{ ok: boolean }>;
+ findById(disputeId: string): Promise;
findByBookingIdAndReporter(bookingId: string, reporterUserId: string): Promise;
findByReporterUserId(reporterUserId: string): Promise;
findByStatus(status: DisputeStatus): Promise;
+ findByStatuses(statuses: DisputeStatus[]): Promise;
+ transitionStatus(input: {
+ disputeId: string;
+ allowedCurrentStatuses: DisputeStatus[];
+ nextStatus: DisputeStatus;
+ resolvedAt?: string | null;
+ resolutionNote?: string | null;
+ }): Promise<
+ | { ok: true; dispute: DisputeRecord; replayed: boolean }
+ | { ok: false; reason: 'not-found' }
+ | { ok: false; reason: 'transition-conflict'; currentStatus: DisputeStatus }
+ >;
}
export const DISPUTE_REPOSITORY = Symbol('DISPUTE_REPOSITORY');
diff --git a/services/platform-api/src/disputes/infrastructure/dispute-repository.provider.test.ts b/services/platform-api/src/disputes/infrastructure/dispute-repository.provider.test.ts
new file mode 100644
index 0000000..707fb59
--- /dev/null
+++ b/services/platform-api/src/disputes/infrastructure/dispute-repository.provider.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+
+import { PostgresClient } from '../../persistence/postgres-client';
+import { resolveDisputeRepository } from './dispute-repository.provider';
+import { InMemoryDisputeRepository } from './in-memory-dispute.repository';
+import { PostgresDisputeRepository } from './postgres-dispute.repository';
+
+describe('resolveDisputeRepository', () => {
+ it('throws when postgres mode is selected without DATABASE_URL', () => {
+ const inMemoryRepository = new InMemoryDisputeRepository();
+
+ expect(() =>
+ resolveDisputeRepository({
+ inMemoryRepository,
+ postgresClient: new PostgresClient(),
+ env: {
+ PERSISTENCE_MODE: 'postgres',
+ },
+ }),
+ ).toThrowError(/DATABASE_URL/i);
+ });
+
+ it('returns in-memory repository by default', () => {
+ const inMemoryRepository = new InMemoryDisputeRepository();
+
+ const repository = resolveDisputeRepository({
+ inMemoryRepository,
+ postgresClient: new PostgresClient(),
+ env: {},
+ });
+
+ expect(repository).toBe(inMemoryRepository);
+ });
+
+ it('returns postgres repository in postgres mode', () => {
+ const inMemoryRepository = new InMemoryDisputeRepository();
+
+ const repository = resolveDisputeRepository({
+ inMemoryRepository,
+ postgresClient: new PostgresClient(),
+ env: {
+ PERSISTENCE_MODE: 'postgres',
+ DATABASE_URL: 'postgres://quickwerk:quickwerk@localhost:5432/quickwerk',
+ },
+ });
+
+ expect(repository).toBeInstanceOf(PostgresDisputeRepository);
+ });
+});
diff --git a/services/platform-api/src/disputes/infrastructure/dispute-repository.provider.ts b/services/platform-api/src/disputes/infrastructure/dispute-repository.provider.ts
index a132aec..5ea0ee9 100644
--- a/services/platform-api/src/disputes/infrastructure/dispute-repository.provider.ts
+++ b/services/platform-api/src/disputes/infrastructure/dispute-repository.provider.ts
@@ -1,9 +1,25 @@
-import { Provider } from '@nestjs/common';
-
-import { DISPUTE_REPOSITORY } from '../domain/dispute.repository';
+import { PostgresClient } from '../../persistence/postgres-client';
+import {
+ requirePostgresPersistenceConfig,
+ resolvePersistenceMode,
+} from '../../persistence/persistence-mode';
+import { DisputeRepository } from '../domain/dispute.repository';
import { InMemoryDisputeRepository } from './in-memory-dispute.repository';
+import { PostgresDisputeRepository } from './postgres-dispute.repository';
+
+export function resolveDisputeRepository(params: {
+ inMemoryRepository: InMemoryDisputeRepository;
+ postgresClient: PostgresClient;
+ env?: NodeJS.ProcessEnv;
+}): DisputeRepository {
+ const env = params.env ?? process.env;
+ const mode = resolvePersistenceMode(env);
+
+ if (mode === 'in-memory') {
+ return params.inMemoryRepository;
+ }
+
+ const postgresConfig = requirePostgresPersistenceConfig(env);
-export const disputeRepositoryProvider: Provider = {
- provide: DISPUTE_REPOSITORY,
- useExisting: InMemoryDisputeRepository,
-};
+ return new PostgresDisputeRepository(params.postgresClient, postgresConfig);
+}
diff --git a/services/platform-api/src/disputes/infrastructure/in-memory-dispute.repository.test.ts b/services/platform-api/src/disputes/infrastructure/in-memory-dispute.repository.test.ts
new file mode 100644
index 0000000..68c9b9e
--- /dev/null
+++ b/services/platform-api/src/disputes/infrastructure/in-memory-dispute.repository.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest';
+
+import { InMemoryDisputeRepository } from './in-memory-dispute.repository';
+
+describe('InMemoryDisputeRepository', () => {
+ it('applies transition and supports idempotent replay', async () => {
+ const repository = new InMemoryDisputeRepository();
+
+ const disputeId = 'd-1';
+ await repository.save({
+ disputeId,
+ bookingId: 'b-1',
+ reporterUserId: 'u-1',
+ reporterRole: 'customer',
+ category: 'quality',
+ description: 'Bad quality',
+ status: 'open',
+ createdAt: new Date().toISOString(),
+ resolvedAt: null,
+ resolutionNote: null,
+ });
+
+ const transitioned = await repository.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses: ['open'],
+ nextStatus: 'under-review',
+ });
+
+ expect(transitioned.ok).toBe(true);
+ if (!transitioned.ok) return;
+ expect(transitioned.dispute.status).toBe('under-review');
+ expect(transitioned.replayed).toBe(false);
+
+ const replay = await repository.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses: ['open'],
+ nextStatus: 'under-review',
+ });
+
+ expect(replay.ok).toBe(true);
+ if (!replay.ok) return;
+ expect(replay.replayed).toBe(true);
+ });
+
+ it('returns transition conflict when current status is not allowed', async () => {
+ const repository = new InMemoryDisputeRepository();
+ const disputeId = 'd-2';
+
+ await repository.save({
+ disputeId,
+ bookingId: 'b-2',
+ reporterUserId: 'u-2',
+ reporterRole: 'provider',
+ category: 'billing',
+ description: 'Billing issue',
+ status: 'resolved',
+ createdAt: new Date().toISOString(),
+ resolvedAt: new Date().toISOString(),
+ resolutionNote: 'Done',
+ });
+
+ const result = await repository.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses: ['under-review'],
+ nextStatus: 'closed',
+ });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('transition-conflict');
+ });
+});
diff --git a/services/platform-api/src/disputes/infrastructure/in-memory-dispute.repository.ts b/services/platform-api/src/disputes/infrastructure/in-memory-dispute.repository.ts
index 67fa457..32b4cc3 100644
--- a/services/platform-api/src/disputes/infrastructure/in-memory-dispute.repository.ts
+++ b/services/platform-api/src/disputes/infrastructure/in-memory-dispute.repository.ts
@@ -9,22 +9,85 @@ export class InMemoryDisputeRepository implements DisputeRepository {
private readonly disputes = new Map();
async save(dispute: DisputeRecord): Promise<{ ok: boolean }> {
- this.disputes.set(dispute.disputeId, dispute);
+ this.disputes.set(dispute.disputeId, structuredClone(dispute));
return { ok: true };
}
+ async findById(disputeId: string): Promise {
+ const record = this.disputes.get(disputeId);
+ return record ? structuredClone(record) : null;
+ }
+
async findByBookingIdAndReporter(bookingId: string, reporterUserId: string): Promise {
const found = Array.from(this.disputes.values()).find(
(d) => d.bookingId === bookingId && d.reporterUserId === reporterUserId,
);
- return found ?? null;
+ return found ? structuredClone(found) : null;
}
async findByReporterUserId(reporterUserId: string): Promise {
- return Array.from(this.disputes.values()).filter((d) => d.reporterUserId === reporterUserId);
+ return Array.from(this.disputes.values())
+ .filter((d) => d.reporterUserId === reporterUserId)
+ .map((d) => structuredClone(d));
}
async findByStatus(status: DisputeStatus): Promise {
- return Array.from(this.disputes.values()).filter((d) => d.status === status);
+ return Array.from(this.disputes.values())
+ .filter((d) => d.status === status)
+ .map((d) => structuredClone(d));
+ }
+
+ async findByStatuses(statuses: DisputeStatus[]): Promise {
+ const allowed = new Set(statuses);
+ return Array.from(this.disputes.values())
+ .filter((d) => allowed.has(d.status))
+ .map((d) => structuredClone(d));
+ }
+
+ async transitionStatus(input: {
+ disputeId: string;
+ allowedCurrentStatuses: DisputeStatus[];
+ nextStatus: DisputeStatus;
+ resolvedAt?: string | null;
+ resolutionNote?: string | null;
+ }): Promise<
+ | { ok: true; dispute: DisputeRecord; replayed: boolean }
+ | { ok: false; reason: 'not-found' }
+ | { ok: false; reason: 'transition-conflict'; currentStatus: DisputeStatus }
+ > {
+ const current = this.disputes.get(input.disputeId);
+ if (!current) {
+ return { ok: false, reason: 'not-found' };
+ }
+
+ const nextResolutionNote = input.resolutionNote === undefined ? current.resolutionNote : input.resolutionNote;
+
+ if (current.status === input.nextStatus) {
+ // Normalize resolutionNote for comparison (treat null and undefined as equivalent)
+ const normalizedInputNote = nextResolutionNote === undefined ? null : nextResolutionNote;
+ const normalizedCurrentNote = current.resolutionNote === undefined ? null : current.resolutionNote;
+ const resolutionNoteMatches = normalizedInputNote === normalizedCurrentNote;
+
+ // Ignore resolvedAt differences (server-assigned), check only resolutionNote intent
+ if (resolutionNoteMatches) {
+ return { ok: true, dispute: structuredClone(current), replayed: true };
+ }
+
+ return { ok: false, reason: 'transition-conflict', currentStatus: current.status };
+ }
+
+ if (!input.allowedCurrentStatuses.includes(current.status)) {
+ return { ok: false, reason: 'transition-conflict', currentStatus: current.status };
+ }
+
+ const updated: DisputeRecord = {
+ ...current,
+ status: input.nextStatus,
+ resolvedAt: input.resolvedAt === undefined ? current.resolvedAt : input.resolvedAt,
+ resolutionNote: nextResolutionNote,
+ };
+
+ this.disputes.set(updated.disputeId, structuredClone(updated));
+ return { ok: true, dispute: structuredClone(updated), replayed: false };
}
-}
+}
\ No newline at end of file
diff --git a/services/platform-api/src/disputes/infrastructure/postgres-dispute.repository.ts b/services/platform-api/src/disputes/infrastructure/postgres-dispute.repository.ts
new file mode 100644
index 0000000..01eca10
--- /dev/null
+++ b/services/platform-api/src/disputes/infrastructure/postgres-dispute.repository.ts
@@ -0,0 +1,306 @@
+import type { DisputeRecord, DisputeStatus } from '@quickwerk/domain';
+
+import { PostgresClient } from '../../persistence/postgres-client';
+import { PostgresPersistenceConfig } from '../../persistence/persistence-mode';
+import { DisputeRepository } from '../domain/dispute.repository';
+
+type DisputeRow = {
+ id: string;
+ booking_id: string;
+ reporter_user_id: string;
+ reporter_role: 'customer' | 'provider';
+ category: DisputeRecord['category'];
+ description: string;
+ status: DisputeStatus;
+ created_at: Date | string;
+ resolved_at: Date | string | null;
+ resolution_note: string | null;
+};
+
+export class PostgresDisputeRepository implements DisputeRepository {
+ constructor(
+ private readonly postgresClient: PostgresClient,
+ private readonly postgresConfig: PostgresPersistenceConfig,
+ ) {}
+
+ async save(dispute: DisputeRecord): Promise<{ ok: boolean }> {
+ const result = await this.postgresClient.query(
+ this.postgresConfig,
+ `INSERT INTO disputes (
+ id,
+ booking_id,
+ reporter_user_id,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note
+ ) VALUES (
+ $1::uuid,
+ $2::uuid,
+ $3::uuid,
+ $4,
+ $5,
+ $6,
+ $7,
+ $8::timestamptz,
+ $9::timestamptz,
+ $10
+ ) ON CONFLICT (booking_id, reporter_user_id) DO NOTHING`,
+ [
+ dispute.disputeId,
+ dispute.bookingId,
+ dispute.reporterUserId,
+ dispute.reporterRole,
+ dispute.category,
+ dispute.description,
+ dispute.status,
+ dispute.createdAt,
+ dispute.resolvedAt,
+ dispute.resolutionNote,
+ ],
+ );
+
+ return { ok: (result.rowCount ?? 0) > 0 };
+ }
+
+ async findById(disputeId: string): Promise {
+ if (!isUuid(disputeId)) {
+ return null;
+ }
+
+ const result = await this.postgresClient.query(
+ this.postgresConfig,
+ `SELECT id::text,
+ booking_id::text,
+ reporter_user_id::text,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note
+ FROM disputes
+ WHERE id = $1::uuid
+ LIMIT 1`,
+ [disputeId],
+ );
+
+ return result.rows[0] ? mapDisputeRow(result.rows[0]) : null;
+ }
+
+ async findByBookingIdAndReporter(bookingId: string, reporterUserId: string): Promise {
+ if (!isUuid(bookingId) || !isUuid(reporterUserId)) {
+ return null;
+ }
+
+ const result = await this.postgresClient.query(
+ this.postgresConfig,
+ `SELECT id::text,
+ booking_id::text,
+ reporter_user_id::text,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note
+ FROM disputes
+ WHERE booking_id = $1::uuid AND reporter_user_id = $2::uuid
+ LIMIT 1`,
+ [bookingId, reporterUserId],
+ );
+
+ return result.rows[0] ? mapDisputeRow(result.rows[0]) : null;
+ }
+
+ async findByReporterUserId(reporterUserId: string): Promise {
+ if (!isUuid(reporterUserId)) {
+ return [];
+ }
+
+ const result = await this.postgresClient.query(
+ this.postgresConfig,
+ `SELECT id::text,
+ booking_id::text,
+ reporter_user_id::text,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note
+ FROM disputes
+ WHERE reporter_user_id = $1::uuid
+ ORDER BY created_at DESC`,
+ [reporterUserId],
+ );
+
+ return result.rows.map(mapDisputeRow);
+ }
+
+ async findByStatus(status: DisputeStatus): Promise {
+ const result = await this.postgresClient.query(
+ this.postgresConfig,
+ `SELECT id::text,
+ booking_id::text,
+ reporter_user_id::text,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note
+ FROM disputes
+ WHERE status = $1
+ ORDER BY created_at DESC`,
+ [status],
+ );
+
+ return result.rows.map(mapDisputeRow);
+ }
+
+ async findByStatuses(statuses: DisputeStatus[]): Promise {
+ const result = await this.postgresClient.query(
+ this.postgresConfig,
+ `SELECT id::text,
+ booking_id::text,
+ reporter_user_id::text,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note
+ FROM disputes
+ WHERE status = ANY($1::text[])
+ ORDER BY created_at ASC`,
+ [statuses],
+ );
+
+ return result.rows.map(mapDisputeRow);
+ }
+
+ async transitionStatus(input: {
+ disputeId: string;
+ allowedCurrentStatuses: DisputeStatus[];
+ nextStatus: DisputeStatus;
+ resolvedAt?: string | null;
+ resolutionNote?: string | null;
+ }): Promise<
+ | { ok: true; dispute: DisputeRecord; replayed: boolean }
+ | { ok: false; reason: 'not-found' }
+ | { ok: false; reason: 'transition-conflict'; currentStatus: DisputeStatus }
+ > {
+ if (!isUuid(input.disputeId)) {
+ return { ok: false, reason: 'not-found' };
+ }
+
+ return this.postgresClient.withTransaction(async (client) => {
+ const currentResult = await client.query(
+ `SELECT id::text,
+ booking_id::text,
+ reporter_user_id::text,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note
+ FROM disputes
+ WHERE id = $1::uuid
+ FOR UPDATE`,
+ [input.disputeId],
+ );
+
+ const current = currentResult.rows[0];
+
+ if (!current) {
+ return { ok: false, reason: 'not-found' } as const;
+ }
+
+ const nextResolvedAt = input.resolvedAt === undefined ? current.resolved_at : input.resolvedAt;
+ const nextResolutionNote = input.resolutionNote === undefined ? current.resolution_note : input.resolutionNote;
+
+ if (current.status === input.nextStatus) {
+ // Normalize resolutionNote for comparison (treat null and undefined as equivalent)
+ const normalizedInputNote = nextResolutionNote === undefined ? null : nextResolutionNote;
+ const normalizedCurrentNote = current.resolution_note === undefined ? null : current.resolution_note;
+ const resolutionNoteMatches = normalizedInputNote === normalizedCurrentNote;
+
+ // Ignore resolvedAt differences (server-assigned), check only resolutionNote intent
+ if (resolutionNoteMatches) {
+ return { ok: true, dispute: mapDisputeRow(current), replayed: true } as const;
+ }
+
+ return { ok: false, reason: 'transition-conflict', currentStatus: current.status } as const;
+ }
+
+ if (!input.allowedCurrentStatuses.includes(current.status)) {
+ return { ok: false, reason: 'transition-conflict', currentStatus: current.status } as const;
+ }
+
+ const updatedResult = await client.query(
+ `UPDATE disputes
+ SET status = $2,
+ resolved_at = $3::timestamptz,
+ resolution_note = $4
+ WHERE id = $1::uuid
+ RETURNING id::text,
+ booking_id::text,
+ reporter_user_id::text,
+ reporter_role,
+ category,
+ description,
+ status,
+ created_at,
+ resolved_at,
+ resolution_note`,
+ [input.disputeId, input.nextStatus, nextResolvedAt, nextResolutionNote],
+ );
+
+ const updated = updatedResult.rows[0];
+
+ if (!updated) {
+ return { ok: false, reason: 'not-found' } as const;
+ }
+
+ return { ok: true, dispute: mapDisputeRow(updated), replayed: false } as const;
+ }, {
+ ...process.env,
+ DATABASE_URL: this.postgresConfig.databaseUrl,
+ PERSISTENCE_MODE: 'postgres',
+ });
+ }
+}
+
+function mapDisputeRow(row: DisputeRow): DisputeRecord {
+ return {
+ disputeId: row.id,
+ bookingId: row.booking_id,
+ reporterUserId: row.reporter_user_id,
+ reporterRole: row.reporter_role,
+ category: row.category,
+ description: row.description,
+ status: row.status,
+ createdAt: toIsoString(row.created_at),
+ resolvedAt: row.resolved_at ? toIsoString(row.resolved_at) : null,
+ resolutionNote: row.resolution_note,
+ };
+}
+
+function toIsoString(value: Date | string): string {
+ return new Date(value).toISOString();
+}
+
+function isUuid(value: string): boolean {
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
+}
\ No newline at end of file
diff --git a/services/platform-api/src/modules/index.ts b/services/platform-api/src/modules/index.ts
index 7d82be3..27888c9 100644
--- a/services/platform-api/src/modules/index.ts
+++ b/services/platform-api/src/modules/index.ts
@@ -1 +1 @@
-export const platformApiModules = ['auth', 'bookings', 'health', 'operators'] as const;
+export const platformApiModules = ['auth', 'bookings', 'disputes', 'health', 'operators'] as const;
diff --git a/services/platform-api/src/persistence/postgres-mode.integration.test.ts b/services/platform-api/src/persistence/postgres-mode.integration.test.ts
index 3f09c91..807cf2c 100644
--- a/services/platform-api/src/persistence/postgres-mode.integration.test.ts
+++ b/services/platform-api/src/persistence/postgres-mode.integration.test.ts
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PostgresAuthSessionRepository } from '../auth/infrastructure/postgres-auth-session.repository';
import { PostgresBookingRepository } from '../bookings/infrastructure/postgres-booking.repository';
+import { PostgresDisputeRepository } from '../disputes/infrastructure/postgres-dispute.repository';
import { PostgresClient } from './postgres-client';
const shouldRun = process.env.RUN_POSTGRES_INTEGRATION_TESTS === '1';
@@ -15,9 +16,13 @@ describe.runIf(shouldRun && Boolean(databaseUrl))('postgres mode integration (op
const authRepository = new PostgresAuthSessionRepository(postgresClient, config);
const bookingRepository = new PostgresBookingRepository(postgresClient, config);
+ const disputeRepository = new PostgresDisputeRepository(postgresClient, config);
beforeAll(async () => {
- await postgresClient.query(config, 'TRUNCATE TABLE booking_status_history, bookings, sessions, users RESTART IDENTITY CASCADE');
+ await postgresClient.query(
+ config,
+ 'TRUNCATE TABLE disputes, booking_status_history, bookings, sessions, users RESTART IDENTITY CASCADE',
+ );
});
afterAll(async () => {
@@ -97,4 +102,91 @@ describe.runIf(shouldRun && Boolean(databaseUrl))('postgres mode integration (op
await expect(authRepository.deleteSession(customerSession.token)).resolves.toBe(true);
await expect(authRepository.resolveSession(customerSession.token)).resolves.toBeNull();
});
+
+ it('persists dispute transitions end-to-end', async () => {
+ const customerSession = await authRepository.createSession({
+ email: 'integration.dispute.customer@quickwerk.local',
+ role: 'customer',
+ });
+
+ const providerSession = await authRepository.createSession({
+ email: 'integration.dispute.provider@quickwerk.local',
+ role: 'provider',
+ });
+
+ const booking = await bookingRepository.createSubmittedBooking({
+ createdAt: new Date().toISOString(),
+ customerUserId: customerSession.userId,
+ requestedService: 'Dispute integration service',
+ actorRole: 'customer',
+ actorUserId: customerSession.userId,
+ });
+
+ await bookingRepository.acceptSubmittedBooking({
+ bookingId: booking.bookingId,
+ acceptedAt: new Date().toISOString(),
+ providerUserId: providerSession.userId,
+ actorRole: 'provider',
+ actorUserId: providerSession.userId,
+ });
+
+ const disputeId = '0f8fad5b-d9cb-469f-a165-70867728950e';
+ await disputeRepository.save({
+ disputeId,
+ bookingId: booking.bookingId,
+ reporterUserId: customerSession.userId,
+ reporterRole: 'customer',
+ category: 'quality',
+ description: 'Integration dispute.',
+ status: 'open',
+ createdAt: new Date().toISOString(),
+ resolvedAt: null,
+ resolutionNote: null,
+ });
+
+ const reviewTransition = await disputeRepository.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses: ['open'],
+ nextStatus: 'under-review',
+ });
+ expect(reviewTransition.ok).toBe(true);
+ if (!reviewTransition.ok) return;
+ expect(reviewTransition.dispute.status).toBe('under-review');
+
+ const resolveTransition = await disputeRepository.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses: ['under-review'],
+ nextStatus: 'resolved',
+ resolvedAt: new Date().toISOString(),
+ resolutionNote: 'Resolved in integration test.',
+ });
+ expect(resolveTransition.ok).toBe(true);
+ if (!resolveTransition.ok) return;
+ expect(resolveTransition.dispute.status).toBe('resolved');
+ expect(resolveTransition.dispute.resolutionNote).toContain('integration test');
+
+ const replayResolve = await disputeRepository.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses: ['under-review'],
+ nextStatus: 'resolved',
+ resolvedAt: new Date().toISOString(),
+ resolutionNote: 'Resolved in integration test.',
+ });
+ expect(replayResolve.ok).toBe(true);
+ if (replayResolve.ok) {
+ expect(replayResolve.replayed).toBe(true);
+ expect(replayResolve.dispute.status).toBe('resolved');
+ }
+
+ const invalidClose = await disputeRepository.transitionStatus({
+ disputeId,
+ allowedCurrentStatuses: ['under-review'],
+ nextStatus: 'closed',
+ });
+ expect(invalidClose.ok).toBe(false);
+ if (!invalidClose.ok && invalidClose.reason === 'transition-conflict') {
+ expect(invalidClose.reason).toBe('transition-conflict');
+ expect(invalidClose.currentStatus).toBe('resolved');
+ }
+ });
});