import { clientFetch } from '@/lib/api/client'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; import type { PageParams } from '@/lib/api/types'; import { REVIEWS_PAGE_SIZE } from '../constants'; import type { CreateReviewRequest, ModerateReviewResult, ModerationAction, ModerationQueueFilters, ModerationQueueItem, MyReviewState, NurseReviews, ReviewEligibility, ReviewListItem, ReviewsApi, SubmitReviewResult, } from '../types'; const API = '/api/v1'; /** Wire `NurseReviewsResult` — `reviews` is a `PagedResult` (camelCase, per api-conventions). */ interface NurseReviewsWire { aggregate: { averageRating: number; publishedCount: number }; reviews: Paginated; } /** Wire `MyReviewDto` (REQ-026) — keys the moderation state as `moderationStatus` (`'none'` when unreviewed). */ interface MyReviewDto { moderationStatus: MyReviewState['status']; rating: number | null; body: string | null; tagCodes: string[]; createdAt: string | null; } /** Wire `ModerationQueueItemDto` — carries `tagCodes` since REQ-037 (delivered in refinement-phase-3). */ type ModerationQueueItemWire = ModerationQueueItem; /** * Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`, * swagger `dev/contracts/openapi/swagger.v1.json`). Two of the four methods map **published** b14 routes: * - `getNurseReviews` → `GET nurses/{id}/reviews` (aggregate + published page; server filters to published). * - `createReview` → `POST bookings/{id}/review` (the one review per completed booking; `409` if reviewed). * * The other two target contract gaps the frontend filed (**REQ-026**), which is why the domain stays * mock-primary (see `constants.ts`): * - `getReviewEligibility` → whether this booking can still be reviewed (no wire read; proposed slug below). * - `getMyReviewForBooking` → the caller's own review + its moderation state (no wire read; proposed slug). * * NOT the primary implementation this phase (`USE_REVIEWS_MOCK = true`). `clientFetch` returns the raw * envelope so we `unwrap()`; ids come from the route; list params are camelCase (`page`/`pageSize`). */ export const reviewsClientApi: ReviewsApi = { getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise => { const query = new URLSearchParams(); query.set('page', String(params.page ?? 1)); query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE)); const wire = unwrap( await clientFetch>( `${API}/nurses/${nurseProfileId}/reviews?${query.toString()}`, ), ); return { aggregate: wire.aggregate, reviews: wire.reviews }; }, // REQ-026 (delivered): owner-scoped eligibility read. Wire `reason` is nullable; normalise to `undefined`. getReviewEligibility: async (bookingId: number): Promise => { const wire = unwrap( await clientFetch>( `${API}/bookings/${bookingId}/review_eligibility`, ), ); return { canReview: wire.canReview, reason: wire.reason ?? undefined }; }, // REQ-026 (delivered): the caller's own review for this booking + its moderation state. The wire dto keys // the state as `moderationStatus` (incl. `'none'` when unreviewed); the client model calls it `status`. getMyReviewForBooking: async (bookingId: number): Promise => { const wire = unwrap( await clientFetch>(`${API}/bookings/${bookingId}/my_review`), ); return { status: wire.moderationStatus, rating: wire.rating, body: wire.body, tagCodes: wire.tagCodes ?? [], createdAt: wire.createdAt, }; }, createReview: async (bookingId: number, body: CreateReviewRequest): Promise => unwrap( await clientFetch>(`${API}/bookings/${bookingId}/review`, { method: 'POST', body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }), }), ), listModerationQueue: async (filters, params): Promise> => { const query = new URLSearchParams(); query.set('status', filters.status ?? 'pending_moderation'); query.set('page', String(params.page ?? 1)); query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE)); const wire = unwrap( await clientFetch>>( `${API}/admin/reviews/moderation_queue?${query.toString()}`, ), ); // REQ-037 (delivered): the wire dto carries tagCodes; default to [] only if the server omits it. return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: item.tagCodes ?? [] })) }; }, moderateReview: async ( reviewId: number, action: ModerationAction, reason?: string, ): Promise => unwrap( await clientFetch>(`${API}/reviews/${reviewId}/status`, { method: 'PATCH', body: JSON.stringify({ action, reason: reason ?? null }), }), ), };