Files
nobat724_front/.claude/prompt/fix-appointment-booking-flow.md
hamedandClaude Opus 4.8 811882ea05 fix(appointment): align request layer with real backend contract
- getAppointmentSlots uses doctor_uuid + date (was doctor_id, which the
  backend rejects with دکتر یافت نشد).
- postAppointmentPayment posts to /api/v1/payment/appointment (was the
  nonexistent /api/v1/payment).
- Remove getAppointmentNotAvailable: the not-available route does not
  exist (404); disabled dates come from the slots response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:35:43 +03:30

19 KiB
Raw Permalink Blame History

بازنویسی صفحه‌ی گرفتن نوبت (/appointment/[doctorId]) برای هم‌خوانی با API واقعی

پروژه

nobat724_front — سایت عمومی نوبت‌دهی. این کار صرفاً frontend است؛ backend (clinicpro) کامل و درست است و تغییر نمی‌کند. مشکل این است که کل فلوی نوبت‌گیری روی یک قرارداد API خیالی/قدیمی نوشته شده که با endpointهای واقعی clinicpro هم‌خوان نیست. همه‌ی endpointهای لازم از قبل در clinicpro/docs/api/appointment.md و clinicpro/docs/api/payment.md مستند و آماده‌اند.

زمینه

صفحه‌ی /appointment/[doctorId] یک wizard چندمرحله‌ای است:

step 0: انتخاب تاریخ + ساعت (Date) → step 1/2: لاگین OTP → step 3: تکمیل اطلاعات (Detail) → step 4: پرداخت (Paying) → step 5/6: success/failed

این فلو در ظاهر کامل است ولی عملاً کار نمی‌کند چون هر چهار فراخوانی اصلی API (اسلات‌ها، رزرو، پروفایل، پرداخت) با قرارداد واقعی backend مغایرت دارند و علاوه بر آن صفحه از یک endpoint که اصلاً وجود ندارد (appointment/not-available/{id}) استفاده می‌کند.

مشکل / هدف

صفحه‌ی نوبت‌گیری را به API واقعی وصل کن: استخراج درست پاسخ‌ها، پارامترها و بدنه‌های درست، و مپ‌کردن ساختار واقعی اسلات (sessions[].slots[]) به UI موجود. هدف این است که یک کاربر واقعی بتواند تاریخ/ساعت را ببیند، اسلات انتخاب کند، نوبت رزرو کند و به درگاه پرداخت برود.

این کار باید مرحله‌به‌مرحله انجام شود (یک قابلیت در هر مرحله: پیاده‌سازی → build → تأیید → commit).

قرارداد واقعی Backend (تأییدشده با curl روی https://clinic-pro.ddev.site)

۱. اسلات‌ها — GET /api/v1/appointment-slots?doctor_uuid={uuid}&date={Y-m-d} (PUBLIC)

  • پارامتر doctor_uuid (UUID) است، نه doctor_id. فراخوانی با doctor_id خطای ERR_VALIDATION_002 («دکتر یافت نشد») می‌دهد.
  • پاسخ:
{
  "success": true,
  "data": {
    "doctor_uuid": "...",
    "date": "2026-06-20",
    "sessions": [
      {
        "start_time": "09:00",
        "end_time": "13:00",
        "slots": [
          { "start": 1781933400, "end": 1781934600, "start_time": "09:00", "end_time": "09:20", "location_id": 2581, "is_available": true }
        ]
      },
      { "start_time": "15:00", "end_time": "17:00", "slots": [ ... ] }
    ]
  }
}

هیچ کلید morning/evening/hours_morning/hours_afternoon وجود ندارد. اسلات کلید time/status ندارد؛ به‌جایش start_time (رشته‌ی HH:MM)، is_available (boolean)، و start/end (Unix timestamp) دارد. شیفت‌ها در آرایه‌ی sessions هستند (نه دو دسته‌ی ثابت صبح/عصر).

۲. رزرو نوبت — POST /api/v1/appointment (AUTH)

بدنه:

{ "doctor_uuid": "...", "slot_start": 1781933400, "slot_end": 1781934600, "note": "" }
  • doctor_uuid (نه doctor_idslot_start/slot_end به‌صورت Unix timestamp (همان start/end اسلات)، note اختیاری (نه info، نه شیء slot).
  • پاسخ 201: { success, data: { uuid, status: "pending", price, ... } } — شناسه‌ی نوبت data.uuid است (UUID)، نه data.id.

۳. پرداخت — POST /api/v1/payment/appointment (AUTH، باید مالک نوبت باشد)

بدنه:

{ "appointment_uuid": "...", "gateway": "mellat", "frontend_address": "https://.../payment/result" }
  • مسیر payment/appointment است، نه payment. gateway فقط "mellat" یا "sep". فیلدهای payment_method/bundle/entity_reference_id وجود ندارند.
  • پاسخ 200: { success, data: { payment_uuid, redirect_url, order_id } } — برای ریدایرکت از data.redirect_url استفاده کن (نه ساختن دستی URL).

۴. پروفایل کاربر — GET /api/v1/user-profile/{uuid} (AUTH)

پاسخ { success, data: {...} }.

۵. interceptor مهم (services/api.js)

api.interceptors.response.use((response) => response.data, ...)

همه‌ی request.* یک‌بار پاسخ را باز می‌کنند و بدنه‌ی HTTP ({ success, data }) را برمی‌گردانند. پس داده‌ی واقعی همیشه در result.data است. (نکته: تابع server-side fetchReq در lib/req.js هم response.data را برمی‌گرداند؛ ولی صفحه‌ی نوبت با axiosInstance.get(...) مستقیم کار می‌کند که باز نمی‌کند → آنجا .data لازم است.)

هشدار double-nesting: پاسخ GET /api/v1/doctor/{uuid} سه‌لایه است ({ success, data: { data: {...} } }). در axiosInstance خام یعنی res.data.data.data. (این الگو قبلاً در app/doctor/[slug]/page.js با res.data?.data?.data رفع شده — همان‌جا را مرجع بگیر.)

فایل‌های مرتبط

فایل نقش مشکل
app/appointment/[doctorId]/page.js server: doctor + disabledDates doctor = doctorRes.data (باید ?.data?.data)؛ فراخوانی endpoint ناموجود appointment/not-available/{id}
services/response.js لایه‌ی request.* getAppointment با doctor_id؛ postAppointment/postPayment/getPayment با مسیر/بدنه‌ی غلط؛ getAppointmentNotAvailable به route ناموجود
app/component/date/dateTime/index.js fetch اسلات‌ها + آدرس getAppointment(doctor.id, date)؛ خواندن res.morning/res.evening؛ res.address
app/component/date/dateTime/hours/List.js grid اسلات‌ها appo[value ? "evening" : "morning"]، item.time، item.status — هیچ‌کدام در API نیست
app/component/date/dateTime/SendAppo.js دکمه‌ی «تایید نوبت» فقط setSelectedSlot(hour) — وابسته به شکل hour
components/appointment/Content.js (در components/appointment/index.js) fetch پروفایل کاربر خواندن res.uuid/res.name (باید res.data.*)
components/appointment/detail/SubmitData.js PATCH/POST پروفایل + POST نوبت بدنه‌ی نوبت غلط؛ خواندن appointmentResponse.id (باید data.uuid)
components/appointment/paying/index.js POST پرداخت + ریدایرکت بدنه/مسیر غلط؛ خواندن response.uuid؛ ساختن دستی URL درگاه
clinicpro/docs/api/appointment.md قرارداد اسلات/رزرو مرجع — تغییر نمی‌کند
clinicpro/docs/api/payment.md قرارداد پرداخت مرجع — تغییر نمی‌کند

وضعیت فعلی (کد واقعی مشکل‌دار)

app/appointment/[doctorId]/page.js

const doctorRes = await axiosInstance.get(`${API_URL}/api/v1/doctor/${doctorId}`);
doctor = doctorRes.data;                                  // ❌ باید doctorRes.data?.data?.data

if (doctor && doctor.id) {                                // ❌ doctor.id همیشه undefined
  const disabledDatesRes = await axiosInstance.get(
    `${API_URL}/api/v1/appointment/not-available/${doctor.id}`   // ❌ route وجود ندارد (404)
  );
  disabledDates = disabledDatesRes.data?.data || [];
}

services/response.js

getAppointmentNotAvailable: (doctor_id) =>
  api.get(`api/v1/appointment/not-available/${doctor_id}`, removeTokenHead),   // ❌ route ناموجود
getAppointment: (doctor_id, date) =>
  api.get(`api/v1/appointment-slots?date=${date}&doctor_id=${doctor_id}`, removeTokenHead),  // ❌ doctor_id
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
postPayment: (data) => api.post(`api/v1/payment`, data, { requireAuth: true }),              // ❌ مسیر payment/appointment
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),

app/component/date/dateTime/index.js

request.getAppointment(doctor.id, date).then((res) => {
  setAppo(res);                                                    // ❌ res، نه res.data
  const hasAvailableMorning = res.morning?.some(s => s.status === "available");   // ❌ morning/status نیست
  const hasAvailableEvening = res.evening?.some(s => s.status === "available");
  ...
});
// آدرس:
request.getDoctorAddress(hour.location_id).then((res) => setLocationAddress(res.address));  // ❌ res.data.address

app/component/date/dateTime/hours/List.js

const list = appo && appo[value ? "evening" : "morning"];   // ❌ کلید وجود ندارد
// ...
disabled={item.status !== "available"}                      // ❌ is_available
{item.time}                                                 // ❌ start_time

components/appointment/detail/SubmitData.js

const appointmentPayload = {
  doctor_id: doctor.id,                                     // ❌ doctor_uuid
  slot: {                                                   // ❌ backend شیء slot نمی‌خواهد
    time: selectedSlot.time, status: selectedSlot.status,
    start_time_timestamp: selectedSlot.start_time_timestamp,
    end_time_timestamp: selectedSlot.end_time_timestamp,
    duration_per_patient: selectedSlot.duration_per_patient,
    location_id: selectedSlot.location_id
  },
  info: ""                                                  // ❌ note
};
const appointmentResponse = await request.postAppointment(appointmentPayload);
if (appointmentResponse?.id) setAppointmentId(appointmentResponse.id);   // ❌ data.uuid

components/appointment/paying/index.js

const paymentPayload = {
  payment_method: selectedBank,                             // ❌ gateway
  bundle: "appointment",                                    // ❌ حذف
  entity_reference_id: appointmentId                        // ❌ appointment_uuid
};
const response = await request.postPayment(paymentPayload);
if (response?.uuid) {                                        // ❌ data.redirect_url
  const paymentUrl = `${process.env.NEXT_PUBLIC_API_URL}/payment/${response.uuid}`;  // ❌ از redirect_url استفاده کن
  window.location.href = paymentUrl;
}

components/appointment/index.js (خواندن پروفایل)

const res = await request.getUserProfile(parsedData.uuid);
if (res) {
  const newData = { uuid: res.uuid, ... national_code: { value: res.national_code ... } };  // ❌ res.data.*
}

وظایف

اجرای مرحله‌به‌مرحله؛ بعد از هر قابلیت npm run build و سپس commit جدا.

۱. اصلاح لایه‌ی request.* در services/response.js

  • getAppointment را با doctor_uuid بازنویسی کن (صفحه با [doctorId] کار می‌کند که همان uuid است):
getAppointmentSlots: (doctor_uuid, date) =>
  api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`, removeTokenHead),
  • postAppointment بدنه‌اش از caller می‌آید (در وظیفه ۴ اصلاح می‌شود) — تغییری در امضای تابع لازم نیست.
  • پرداخت را به مسیر و نام درست ببر:
postAppointmentPayment: (data) =>
  api.post(`api/v1/payment/appointment`, data, { requireAuth: true }),
  • getAppointmentNotAvailable و تابع/route ناموجود not-available را حذف کن (در وظیفه ۲ مصرفش هم حذف می‌شود).
  • اسم‌های قدیمی (getAppointment, postPayment) را اگر جای دیگری مصرف نمی‌شوند حذف کن؛ اگر مصرف می‌شوند، همه‌ی callerها را به نسخه‌ی جدید مهاجرت بده. (با grep بررسی کن.)

۲. اصلاح app/appointment/[doctorId]/page.js

  • استخراج درست doctor: doctor = doctorRes.data?.data?.data;
  • منطق disabledDates و فراخوانی appointment/not-available/{id} را حذف کن (route وجود ندارد). prop disabledDates را یا حذف کن یا [] بفرست تا کامپوننت‌های پایین‌دستی نشکنند. تاریخ‌های غیرفعال در همان پاسخ appointment-slots (نبودِ session/اسلات available) منعکس می‌شود؛ منطق غیرفعال‌سازی روز را به آن واگذار کن.
  • disabledDates در Time/SelectDatePicker مصرف می‌شود — بررسی کن با آرایه‌ی خالی رفتار درستی دارد (همه‌ی روزها قابل‌انتخاب) و crash نمی‌کند.

۳. مپ‌کردن ساختار واقعی اسلات به UI — dateTime/index.js + hours/List.js

  • در dateTime/index.js: request.getAppointmentSlots(doctor.uuid, date) را صدا بزن (نه doctor.id؛ پس از وظیفه ۲، doctor شیء واقعی است و uuid دارد).
  • پاسخ در res.data است (interceptor). یک adapter بنویس که data.sessions[] را به ساختاری که UI می‌خواهد تبدیل کند. توهم‌سازی صبح/عصر نکن؛ یا مستقیم روی sessions رندر کن، یا اگر می‌خواهی UI تب‌دار صبح/عصر را نگه داری، sessionها را بر اساس start_time < "12:00" به صبح/عصر تقسیم کن و این تصمیم را در adapter مستندِ کوچک بگذار.
  • در List.js: به‌جای item.time از item.start_time، به‌جای item.status !== "available" از !item.is_available، و به‌جای کلید morning/evening از خروجی adapter استفاده کن.
  • setSelectedSlot(hour) در SendAppo باید شیئی نگه دارد که start و end (Unix) و location_id دارد — همان آبجکت اسلات از API. مطمئن شو این فیلدها تا SubmitData می‌رسند.
  • بخش آدرس: request.getDoctorAddress(hour.location_id) پاسخش res.data است — setLocationAddress(res.data?.address). (شکل واقعی getDoctorAddress را با یک فراخوانی تأیید کن.)

۴. اصلاح ساخت نوبت — components/appointment/detail/SubmitData.js

  • بدنه‌ی درست:
const appointmentPayload = {
  doctor_uuid: doctor.uuid,
  slot_start: selectedSlot.start,
  slot_end: selectedSlot.end,
  note: "",
};
const res = await request.postAppointment(appointmentPayload);
const appointmentUuid = res?.data?.uuid;
if (appointmentUuid) setAppointmentId(appointmentUuid);   // نام state را appointmentUuid نگه‌دار یا همان appointmentId با مقدار uuid
  • خطای 409 (اسلات قبلاً رزرو شده، ERR_CONFLICT_001) را به پیام فارسی مناسب map کن و کاربر را به مرحله‌ی انتخاب ساعت برگردان.
  • بخش PATCH/POST پروفایل قبل از رزرو دست‌نخورده می‌ماند مگر اینکه شکل پاسخ را بشکند — فقط مطمئن شو با interceptor (res.data) هم‌خوان است.

۵. اصلاح پرداخت — components/appointment/paying/index.js

  • بدنه و مسیر درست:
const paymentPayload = {
  appointment_uuid: appointmentId,            // همان uuid نوبت
  gateway: selectedBank,                      // فقط "mellat" یا "sep"
  frontend_address: `${window.location.origin}/payment/result`,
};
const res = await request.postAppointmentPayment(paymentPayload);
const redirectUrl = res?.data?.redirect_url;
if (redirectUrl) {
  window.location.href = redirectUrl;         // از redirect_url خود backend استفاده کن
} else {
  setStep((prev) => prev + 1);
}
  • لیست بانک‌ها (banks) را با gatewayهای واقعی هم‌خوان کن: { id: "mellat", title: "بانک ملت" }، { id: "sep", title: "سامان (سپ)" }. مقدار id باید دقیقاً mellat/sep باشد.
  • مبلغ hard-code شده‌ی «10،000 تومان» را یا از price پاسخ نوبت (data.price، ریال) بگیر و با جداکننده‌ی فارسی به ریال/تومان نمایش بده، یا اگر در این مرحله در دسترس نیست، متن مبلغ ثابت را حذف کن (توهم‌سازی مبلغ ممنوع). price در پاسخ POST /api/v1/appointment هست — می‌توان آن را تا این مرحله پاس داد.

۶. اصلاح خواندن پروفایل — components/appointment/index.js

  • در هر دو useEffect، const res = await request.getUserProfile(parsedData.uuid) پاسخش { success, data } است → از res.data بخوان: res.data.uuid, res.data.national_code, res.data.name, ... . منطق 404 (پروفایل ناموجود → فیلدها قابل‌ویرایش) حفظ شود.

نکات مهم

  • هیچ تغییری در backend لازم نیست. اگر به endpoint غایبی برخوردی، متوقف شو و بپرس (cross-repo) — داده‌ی جعلی یا route حدسی جایگزین نکن. مشخصاً appointment/not-available وجود ندارد و نباید بازسازی شود.
  • interceptor (services/api.js): همه‌ی request.* بدنه‌ی { success, data } را برمی‌گردانند → داده در result.data. صفحه‌ی server-side با axiosInstance خام، doctor را سه‌لایه می‌گیرد (res.data.data.data).
  • slug = uuid: پارامتر route [doctorId] در عمل uuid پزشک است؛ همان را به doctor_uuid بده، نه doctor.id عددی.
  • timestampها Unix هستند: slot.start/slot.end مستقیم به‌عنوان slot_start/slot_end می‌روند؛ تبدیل اضافه نکن.
  • adapter جدا: برای اسلات‌ها یک تابع map کوچک بنویس (sessions[] → ساختار UI) تا کامپوننت نمایشی کم‌تغییر بماند و دیف کوچک شود.
  • Auth: رزرو و پرداخت requireAuth می‌خواهند؛ در 401، api.js خودکار logout/redirect می‌کند — این رفتار را حفظ کن. فلوی OTP (step 1/2) دست‌نخورده می‌ماند.
  • Multi-domain/RTL/Jalali: تاریخ‌ها شمسی، مبالغ ریال با جداکننده‌ی فارسی، matchedCity موجود حفظ شود.
  • تست: بعد از هر قابلیت npm run build (ESLint در این پروژه setup نشده؛ build خود type-check را انجام می‌دهد). در صورت امکان فلو را با یک پزشک واقعی (4a0594b1-008b-478a-a593-259b95d8c2dd) و تاریخ آینده دستی تست کن: اسلات‌ها باید لود شوند. سپس commit با پیام توصیفی برای همان قابلیت.
  • عدم رگرسیون: getAppointment/postPayment قدیمی ممکن است جای دیگری مصرف شوند؛ قبل از حذف/تغییر امضا، با grep -rn "getAppointment\|postPayment\|getAppointmentNotAvailable" app components services همه‌ی callerها را پیدا و مهاجرت بده.