125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
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<ReviewListItemDto>` (camelCase, per api-conventions). */
|
|
interface NurseReviewsWire {
|
|
aggregate: { averageRating: number; publishedCount: number };
|
|
reviews: Paginated<ReviewListItem>;
|
|
}
|
|
|
|
/** 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<NurseReviews> => {
|
|
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<ApiEnvelope<NurseReviewsWire>>(
|
|
`${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<ReviewEligibility> => {
|
|
const wire = unwrap(
|
|
await clientFetch<ApiEnvelope<{ canReview: boolean; reason: ReviewEligibility['reason'] | null }>>(
|
|
`${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<MyReviewState> => {
|
|
const wire = unwrap(
|
|
await clientFetch<ApiEnvelope<MyReviewDto>>(`${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<SubmitReviewResult> =>
|
|
unwrap(
|
|
await clientFetch<ApiEnvelope<SubmitReviewResult>>(`${API}/bookings/${bookingId}/review`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }),
|
|
}),
|
|
),
|
|
|
|
listModerationQueue: async (filters, params): Promise<Paginated<ModerationQueueItem>> => {
|
|
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<ApiEnvelope<Paginated<ModerationQueueItemWire>>>(
|
|
`${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<ModerateReviewResult> =>
|
|
unwrap(
|
|
await clientFetch<ApiEnvelope<ModerateReviewResult>>(`${API}/reviews/${reviewId}/status`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ action, reason: reason ?? null }),
|
|
}),
|
|
),
|
|
};
|