Skip to main content

یکپارچگی فرانت‌اند ↔ بک‌اند

این صفحه توضیح می‌دهد که apps/calendar-web چطور به بک‌اند apps/api وصل می‌شود: سیستمِ تایپِ مشترک، کلاینتِ API، و معماری feature-sliced. برای اضافه‌کردنِ slice جدید یا رفعِ باگِ ارتباطیِ فرانت/بک‌اند این‌جا را بخوانید.

وضعیت فعلی

کلاینتِ وب همین امروز یک کلاینتِ کاملِ همگام‌سازی است: Dexie + outbox + cursor + موتورِ همگام‌سازی + انتخابِ leader بین تب‌های باز + UI تعارض.

سیستم تایپ مشترک: @taghvimam/contracts

packages/contracts منبعِ واحدِ حقیقت برای هر DTOای است که اپ وب مصرف می‌کند: هم اسکیماهای Zod (اعتبارسنجیِ runtime) را دارد و هم تایپ‌های TypeScriptِ استنباط‌شده (تایپِ compile-time) — برای auth، events، calendars، tasks، reminders و اشکالِ مشترکِ envelope/pagination.

چند قاعدهٔ ساده که رعایتشان لازم است:

  • DTO‌ها را همیشه از @taghvimam/contracts ایمپورت کنید، نه از جای دیگر.
  • اگر فیلد یا DTOای کم است، به پکیجِ contracts اضافه‌اش کنید — البته بعد از این‌که شکلِ دقیقش را در برابرِ route handler و service واقعی در apps/api راستی‌آزمایی کردید. هرگز یک تایپِ DTO را به‌صورت محلی در اپ وب دوباره تعریف نکنید؛ وگرنه دو منبعِ حقیقت خواهید داشت که از هم فاصله می‌گیرند.
  • پکیجِ contracts فقط شکل‌های wireِ موجود را توصیف می‌کند؛ بک‌اند را drive نمی‌کند. قراردادِ ثابت با کلاینتِ اندروید همان API بک‌اند و OpenAPI specِ تولیدشده‌اش است، و اپ وب آن را تغییر نمی‌دهد.

کلاینتِ API (src/lib/apiClient.ts)

request<T>(path, options?) تنها نقطهٔ ورودِ HTTP در کلِ اپ است، روی fetch ساخته شده. چند کار را همیشه خودش انجام می‌دهد:

  • bearer token را از tokenStore تزریق می‌کند، مگر این‌که skipAuth ست شده باشد.
  • روی 401: یک‌بار refresh با single-flight (POST /api/auth/refresh) می‌زند و درخواست را دوباره امتحان می‌کند؛ اگر refresh هم شکست بخورد، token‌ها را پاک می‌کند و سیگنالِ logout می‌فرستد (با onLogout می‌توانید مشترک شوید).
  • وقتی یک schema (Zod) بدهید، کلِ payload پاسخ (شاملِ envelope) را اعتبارسنجی می‌کند و برمی‌گرداند؛ استخراجِ .data وظیفهٔ wrapperهای هر feature است.
  • روی non-2xx یا شکستِ اسکیما، یک ApiError (با status + body) پرتاب می‌کند.

نگهداریِ خودِ token هم جدا شده: src/lib/tokenStore.ts یک persistenceِ localStorage‌ای است که قابلِ تعویض است.

معماری feature-sliced (src/features/<domain>/)

هر دامنه همین شکل ثابت را دارد:

فایلمسئولیت
local.tsخواندن/نوشتنِ Dexie + صف‌کردنِ mutation در outbox. مخصوصِ slice‌های آفلاین.
api.tswrapper‌های نازک روی request<T>(). فقط slice‌های غیرِ آفلاین (auth).
mappers.tsنگاشتِ DTO ↔ view-model. view model‌ها در src/types/calendar.types.ts هستند.
hooks.tshook‌های query و mutationِ TanStack Query (با cache invalidation).
components / testsUI (فارسی/RTL، Tailwind) و unit test‌های Vitest.
مسیرِ داده بعد از آفلاین‌اول

events، calendars، tasks و reminders هیچ‌کدام api.ts ندارند — همگی از Dexie می‌خوانند و در outbox (pendingMutations) می‌نویسند، و موتورِ sync در پس‌زمینه با /api/sync/* آشتی می‌کند. مسیرهای مستقلِ REST (/api/events، /api/calendars، /api/reminders، /api/tasks) در بک‌اند حذف شده‌اند؛ اپ وب هیچ‌کدام را مستقیم صدا نمی‌زند.

تنها auth (/api/auth/*) مستقیم REST می‌زند.

slice‌های پیاده‌سازی‌شده

  • auth (REST) — ورودِ OTP/JWT: sendOtp/verifyOtp/getMe/logout، useSendOtp/useVerifyOtp/useMe، AuthContext (hydration از tokenStore، دروازهٔ loading، اشتراکِ onLogoutProtectedRoute، و یک LoginPage دو‌مرحله‌ایِ فارسی. فیلدِ verify یک OTP است به نام otp (نه code). logout علاوه بر پاک‌کردنِ token، wipeDb() را هم صدا می‌زند.
  • events (آفلاین) — local.ts روی جدولِ Dexie events کار می‌کند؛ useEvents(rangeStart, rangeEnd) یک بازهٔ محلی را query می‌کند و چیزی fetch نمی‌کند. mappers.ts عددِ صحیحِ ۳۲بیتِ علامت‌دارِ ARGB color را ↔ #RRGGBB تبدیل می‌کند (alpha هنگام load انداخته می‌شود، هنگام save 0xFF می‌شود) و string‌های ISO ↔ Date. recurrence.ts رویدادهای master تکرارشونده را سمت کلاینت با rrule بسط می‌دهد (expandEvents)، نمونه‌هایی با id‌های پایدارِ ${masterId}:${occurrenceISO} و یک back-reference masterId می‌سازد؛ ویرایش یا حذفِ نمونه، masterِ سری را هدف می‌گیرد (فعلاً فقط کلِ سری، نه یک رخداد تنها). create/edit هنوز recurrence تولید نمی‌کند — بسط فقط برای نمایش است.
  • calendars (آفلاین) — list/create/update/delete روی Dexie؛ toggle دیدِ sidebar هم یک mutationِ محلی است.
  • reminders (آفلاین) — یک Reminder به دقیقاً یکی از Event یا Task وصل می‌شود (eventUid یا taskUid).
  • tasks (آفلاین) — local.ts روی جدولِ Dexie tasks کار می‌کند. سلسله‌مراتبی (parentTaskUid) و تکمیل (completeTask) از طریق Dexie + sync انجام می‌شود.
  • syncConflictBanner و ConflictList روی /api/sync/conflicts، به‌علاوهٔ hook‌های وضعیتِ sync.

View model در برابر DTO

src/types/calendar.types.ts (CalendarEvent، CalendarCategory) یک view model است، نه DTO؛ نگاشتِ بین این دو صراحتاً در mappers.ts هر slice انجام می‌شود. دو نکته که راحت فراموش می‌شوند:

  • CalendarEvent.type و CalendarCategory.type مفاهیمِ صرفاً کلاینت‌ایِ نمایش هستند، بدون معادلِ بک‌اند (بک‌اند type/calendarType را از نوع JALALI|GREGORIAN می‌شناسد). هنگام load به 'meeting'/'other' پیش‌فرض می‌شوند؛ هنگام save حذف می‌شوند — هرگز به/از enumِ بک‌اند نگاشت نمی‌شوند.
  • id‌های سمتِ سرور عددی‌اند؛ view model از string استفاده می‌کند — mapperها numberstring را تبدیل می‌کنند.

متغیرهای محیطی

apps/calendar-web/.env.example این متغیر را document می‌کند:

VITE_API_URL=

در توسعه خالی بگذاریدش — dev serverِ Vite درخواست‌های /api را خودش به بک‌اند پروکسی می‌کند (vite.config.ts). فقط برای یک API غیرِ پروکسی‌شدهٔ تولید آن را روی یک origin مطلق ست کنید، مثل https://api.taghvimam.ir.

گردشکارِ توسعه

از ریشهٔ ریپازیتوری:

npm run dev # Turbo: API روی :5000، وب روی :5173 (پروکسی /api -> :5000)
npm run build # Build contracts -> api -> web
npm run lint # Lint همهٔ workspace‌ها
npm run test # اجرای همهٔ تست‌ها

یا فقط برای وب (از apps/calendar-web):

npm run dev # Vite dev server (:5173)
npm run build # tsc -b && vite build
npm test # vitest run
npm run lint # eslint .

برای آزمایشِ محلیِ جریانِ احراز هویت: اپ وب را باز کنید، به /login بروید، یک شمارهٔ ایرانی وارد کنید (^(\+98|0)?9\d{9}$)، OTP را دریافت یا وارد کنید (در غیرِ تولید سمت سرور لاگ می‌شود)، و به تقویمِ احراز‌شده وارد می‌شوید. اولین verify یک تقویم پیش‌فرض به نام «تقویم اصلی» سمت سرور می‌سازد که getDefaultCalendarId() برای رویدادهای جدید حل می‌کند.