یکپارچگی فرانتاند ↔ بکاند
این صفحه توضیح میدهد که 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.ts | wrapperهای نازک روی request<T>(). فقط sliceهای غیرِ آفلاین (auth). |
mappers.ts | نگاشتِ DTO ↔ view-model. view modelها در src/types/calendar.types.ts هستند. |
hooks.ts | hookهای query و mutationِ TanStack Query (با cache invalidation). |
| components / tests | UI (فارسی/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، اشتراکِonLogout)،ProtectedRoute، و یکLoginPageدومرحلهایِ فارسی. فیلدِ verify یک OTP است به نامotp(نهcode). logout علاوه بر پاککردنِ token،wipeDb()را هم صدا میزند.events(آفلاین) —local.tsروی جدولِ Dexieeventsکار میکند؛useEvents(rangeStart, rangeEnd)یک بازهٔ محلی را query میکند و چیزی fetch نمیکند.mappers.tsعددِ صحیحِ ۳۲بیتِ علامتدارِ ARGBcolorرا ↔#RRGGBBتبدیل میکند (alpha هنگام load انداخته میشود، هنگام save0xFFمیشود) و stringهای ISO ↔Date.recurrence.tsرویدادهای master تکرارشونده را سمت کلاینت باrruleبسط میدهد (expandEvents)، نمونههایی با idهای پایدارِ${masterId}:${occurrenceISO}و یک back-referencemasterIdمیسازد؛ ویرایش یا حذفِ نمونه، masterِ سری را هدف میگیرد (فعلاً فقط کلِ سری، نه یک رخداد تنها). create/edit هنوز recurrence تولید نمیکند — بسط فقط برای نمایش است.calendars(آفلاین) — list/create/update/delete روی Dexie؛ toggle دیدِ sidebar هم یک mutationِ محلی است.reminders(آفلاین) — یک Reminder به دقیقاً یکی از Event یا Task وصل میشود (eventUidیاtaskUid).tasks(آفلاین) —local.tsروی جدولِ Dexietasksکار میکند. سلسلهمراتبی (parentTaskUid) و تکمیل (completeTask) از طریق Dexie + sync انجام میشود.sync—ConflictBannerو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ها
number↔stringرا تبدیل میکنند.
متغیرهای محیطی
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() برای رویدادهای جدید حل میکند.