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'); + } + }); });