Tài liệu system design cho web ứng dụng dành cho giáo viên/trung tâm (repo partner-web, chạy song song hai domain teacher.flyer.us và parent.flyer.us). Mô tả frontend stack, các tầng API (GraphQL v1/v2, REST v3/v4), auth & multi-tenant, provider tree, deployment và observability — dùng làm điểm khởi đầu cho onboarding kỹ sư mới và các quyết định mở rộng.
partner-web là monolith frontend phục vụ ba nhóm người dùng khác nhau qua ba domain (nhưng cùng một Next.js ứng dụng):
| Domain | Persona | Entry route | ENV nhận biết |
|---|---|---|---|
| teacher.flyer.us | Giáo viên · Chủ trung tâm · Admin trường | /explore | APP_ENV=production · SOURCE=partner |
| parent.flyer.us | Phụ huynh · Học sinh (self-serve) | /dashboard-parent | SOURCE=parent → chuyển hướng ở next.config |
| tools.teado.ai | Anonymous dùng thử AI-first | /flyer-ai | Host header rewrite |
Ba lý do vì sao dùng chung một codebase (thay vì tách 3 ứng dụng riêng):
FlyerComponentsV2/ (756 file component) dùng chung cho teacher & parent, tránh drift.Viewer query trả về cả teacherProfile, learnerProfile, schools; Guard chỉ route theo persona.Đánh đổi: bundle size lớn (~450 dependencies), thời gian dev-start chậm (openapi-gen + gqlgen chạy trước Next dev), và provider tree 14 tầng khiến việc trace bug khó hơn — nhưng team đánh giá cost tách nhỏ hơn cost đồng bộ.
┌─────────────────────────────────────────────────┐
│ Trình duyệt: teacher.flyer.us / parent.flyer.us │
└──────────────────────┬──────────────────────────┘
│ HTTPS (cookies + X-Session-Token)
▼
┌────────────────────────┐
│ partner-web │
│ Next.js 15 (K8s pod) │
│ server.js (standalone)│
└──────────┬─────────────┘
│
┌────────────┬────────────┼────────────┬─────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ GraphQL │ │ GraphQL │ │ REST v3 │ │ REST v4 │ │ Static / │
│ API v1 │ │ API v2 │ │ flyer- │ │ teacher- │ │ Media CDN │
│(legacy) │ │(gqlgen) │ │backend- │ │ api │ │ │
│ │ │ │ │rust:3000 │ │ :8081 │ │ │
└────┬────┘ └────┬────┘ └─────┬─────┘ └─────┬─────┘ └───────────┘
│ │ │ │
└─── PostgreSQL 17 (Hasura) ───────────┘
│
▼
Read-replica ap-southeast-1
(analytics · truy vấn nội bộ)
Điểm cần nhớ:
rewrites() — trình duyệt không gọi thẳng vào các backend service. Điều này cho phép Next inject header, log request, và tránh CORS.node_modules vào .next/standaloneignoreBuildErrors: true (chưa strict)ConfigProvider)vi/en)ANALYZE=1)pnpm dev)src/__generated__/ và src/api/__generated__/src/
├── __generated__/ # GraphQL codegen (types + hooks)
├── api/
│ ├── __generated__/ # OpenAPI codegen v3
│ ├── v4/ # OpenAPI codegen v4 (tách khỏi v3)
│ ├── client.ts # openapi-fetch client + interceptors
│ ├── common.ts # GraphQL queries dùng chung
│ └── hooks.ts # Wrapper hooks kết hợp GraphQL + REST
├── app/
│ ├── Guard.tsx # Auth guard, gate route
│ └── LoadingScreen.tsx
├── components/ # 756 file — FlyerComponents / FlyerComponentsV2 + common
├── containers/ # DialogsContainer, ProgressBarContainer
├── layouts/
│ ├── main/ # Layout chính có sidebar
│ ├── authen/ # Login/signup layout
│ └── routeConfig.tsx # Mapping route → layout
├── pages/ # 228 file — Next.js Pages Router
├── providers/ # 14 provider (xem §6)
│ ├── flyer/ # Auth, User, Recap, Onboarding
│ ├── teado/ # Cross-domain session token
│ └── ...
├── store/ # Jotai atoms (theme, assign-test, ai-teacher, …)
├── hooks/ # useSwitchSchool, useNotificationParent, …
├── lib/
│ └── datadog.ts # trackPageView, trackUser, initDatadog
└── utils/
| Lớp | URL trong ứng dụng | Rewrite tới | Codegen | Client | Số endpoint |
|---|---|---|---|---|---|
| GraphQL v1 | /api/graphql | PUBLIC_GRAPHQL_API (Hasura) | graphql-codegen (schema=v2) | Apollo Client 3.13 | ~200 op |
| GraphQL v2 | /api/v2/graphql | PUBLIC_GRAPHQL_API_V2 (gqlgen Go) | graphql-codegen | Apollo Client 3.13 | tăng dần |
| REST v3 | /api/v3/* | flyer-backend-rust:3000 | openapi-typescript | openapi-fetch + React Query | 182 |
| REST v4 | /api/v4/* | teacher-api:8081 | openapi-typescript | openapi-fetch + React Query | 11 (giới hạn 2 module) |
useTeacherFeatureConfigs, GetViewerQuery./library và toàn bộ /wisdom-stones/* (Đá Tri Thức). Đây là "template" để mở rộng backend Go tách trách nhiệm./api/… để Next inject X-Session-Token và giữ cookie same-origin.// src/api/client.ts
apiClient.use({
onRequest({ request }) {
const token = getSessionToken() // đọc từ ?sessTok= hoặc hash hand-off
if (token) request.headers.set('X-Session-Token', token)
return request
},
})
Cookie credentials: 'include' là mặc định. Ngoài ra header device: WEB_TEACHER | WEB_PARENT được set theo hàm isPartner — backend dùng để phân tích device analytics.
// Bất kỳ 401 nào từ REST hoặc lỗi GraphQL với code = NOT_AUTHENTICATED
// đều publish qua sessionExpiredBus → hiện SessionExpiredModal ở root.
apiClient.use({
async onResponse({ response }) {
if (response.status === 401) notifySessionExpired()
else {
const body = await response.clone().json().catch(() => null)
if (body?.code === 'NOT_AUTHENTICATED') notifySessionExpired()
}
return response
},
})
credentials: 'include'.?sessTok= hoặc hash). Được lưu tạm bằng sessionToken util và gắn cho mọi REST call.LoginWithGoogleButton), sau đó backend cấp cookie.app/Guard.tsx chạy sau TeadoProvider, quyết định 3 việc mỗi khi route đổi:
UNAUTHENTICATED_PATHS → redirect login.getCompletedOnboardingRedirect(user) đưa về đúng bước dở dang.?schoolId=… khác currentSchool.schoolId → useSwitchSchool.switchSchool(newId).| Khái niệm | Thực thể DB | Cơ chế chuyển |
|---|---|---|
| School | schools (40.320 rows) | SwitchSchoolMutation đổi currentSchool trong cookie |
| Teacher profile | teacher_profiles (21.107 rows) | Một GV có thể thuộc nhiều trường; activeTeacherProfileId lưu trong Jotai |
| Class | classes (122.856 rows) | Route /classes/[classId] gắn scope theo class |
| Custom domain | custom_domain (703 APPROVED) | White-label — DNS trỏ về pod, backend map subdomain → school_id |
Hệ quả cho developer: mọi query danh sách phải luôn scope theo schoolId từ context (useAuth().currentSchool); nếu quên, sẽ leak data giữa trường — tuy backend cũng chặn nhưng đừng dựa vào đó.
| Loại | Thư viện | Dùng cho |
|---|---|---|
| Server cache — GraphQL | Apollo Client 3.13 | Toàn bộ query GraphQL v1 + v2; typePolicies tùy chỉnh cho writing_check_points, speaking_check_points, speaking_parts |
| Server cache — REST | React Query v5 | Toàn bộ v3 + v4; hooks tự sinh từ openapi |
| Client state | Jotai | loadingGlobal, activeTeacherProfileId, currentWalletAtom, teacherOnboardingInfos, per-flow state (assign-test, give-score, builder-practice, ai-teacher) |
<ConfigProvider antd>
<ReactQueryProvider> // React Query cache
<QueryParamProvider next-adapter> // URL ↔ state
<CacheProvider emotion>
<ThemeConfigProvider> // Custom theme (school branding)
<TailwindThemeProvider>
<RemoteConfigProvider> // Feature flags từ backend
<NotistackProvider> // Snackbar
<FlyerProvider typePolicies=…> // ApolloProvider + AuthProvider + UserProvider
<TeadoProvider> // Cross-domain session bootstrap
<Guard> // 🔒 Auth + route decision
<OnboardingUserProvider>
<ContentPolicyGateProvider> // Chặn content vi phạm chính sách
<PreRouteProvider> // Preload critical data
<Routes> // routeConfig → layout theo pathname
<DataProvider> // Server-side hydrate cho data-heavy pages
<BankAccountDataProvider>
<BankHubProvider> // Payment integration
<RecapProvider> // Modal recap tuần/tháng
<Component /> // 👈 page component
FlyerProvider) thay vì thêm tầng thứ 15.
# Build → Docker → K8s (thủ công qua scripts, chưa có GitHub Actions)
pnpm build-dev → build .next standalone
→ docker buildx build --push registry.flyer.vn/flyer/partner-web:dev
pnpm deploy-dev → kubectl rollout restart deploy partner-web-dev -n development
pnpm build-prod → docker build tag :main
pnpm deploy-prod → kubectl rollout restart deploy partner-web -n production
@beam-australia/react-envContainer entrypoint chạy react-env --env APP_ENV --prefix PUBLIC trước Next → sinh __ENV.js chứa toàn bộ biến PUBLIC_* tại thời điểm start pod. Nhờ vậy một image chung dùng được cho dev/staging/prod, chỉ đổi ConfigMap.
| Env quan trọng | Dùng cho |
|---|---|
PUBLIC_BASE_API | Base URL khi server-side render gọi API |
PUBLIC_BASE_MEDIA_URL | CDN ảnh (dùng qua rewrite /image/*) |
PUBLIC_GRAPHQL_API / _V2 | Endpoint GraphQL v1/v2 |
FLYER_RUST_API / FLYER_BACKEND_V4_API | Server-side rewrite target (không lộ ra client) |
TOOL_URL | Nhận diện host tools.teado.ai để rewrite / → /flyer-ai |
ENTRYPOINT node ./slack/slack.mjs && \
react-env --env APP_ENV --prefix PUBLIC -- node server.js
Slack side-car: mỗi lần pod khởi động, script slack.mjs post message vào Slack (deploy notification / rollback alert). Chạy nối tiếp qua && — nếu Slack fail, pod fail luôn. Đây là điểm phải chú ý khi Slack rate-limit.
initDatadog() gọi trong _app.tsx; forward console.error + custom log.trackUser(user) chạy trong FlyerAuthProvider ngay khi Viewer resolve → gắn userId, schoolId cho toàn bộ session logs.clearUser() chạy khi logout.GoogleAnalytics + GoogleTagManager load qua @next/third-parties/google ở root _app.tsx; page view emit qua router.events.on('routeChangeComplete', …).
| Kịch bản | Cơ chế | UI |
|---|---|---|
| Session hết hạn / cookie xoá | sessionExpiredBus.notify() phát event | SessionExpiredModal ở root, buộc reload sau khi login lại |
| Content policy vi phạm | ContentPolicyGateProvider đọc flag từ Viewer | Full-page gate + hướng dẫn khắc phục |
| Network fail | React Query retry (default 3 lần) | Notistack snackbar (toast) — không block UI |
| Route lỗi (404/500) | Next.js default | Trang pages/404.tsx, 500.tsx tùy chỉnh nhẹ |
| Rủi ro | Mức | Ghi chú |
|---|---|---|
TypeScript strict tắt (ignoreBuildErrors: true) | Cao | Build vẫn pass khi có type error → dễ merge bug. Cần bật strict theo module (module-by-module). |
| Provider tree 14 tầng | Trung bình | Mỗi lần login re-render toàn bộ; khó test unit; onboarding kỹ sư mới lâu. |
| Đồng thời 4 lớp API | Trung bình | Cùng một entity (user, class) có nhiều nguồn khác nhau → cache inconsistency (đã có typePolicies patch nhưng chưa đủ). |
| Slack side-car chặn boot | Thấp-TB | Nếu Slack API 429/timeout → pod restart loop. Nên đổi sang node slack.mjs & … hoặc tách CronJob. |
Không có CI (không có .github/workflows) | Cao | Build/test/deploy đều thủ công qua script + kubectl. Không có gate về lint/type/test trước merge. |
| 228 file page (Pages Router) | Cần theo dõi | Nhiều page kế thừa nhau qua HOC/wrap → khó reason về data fetching. Migrate Ứng dụng Router sẽ giúp nhưng chi phí cao. |
| openapi-fetch không type-narrow 4xx | Cần theo dõi | Response type union {data, error} — dev dễ quên check error. Có wrapper hooks nhưng chưa dùng nhất quán. |
| Bundle size chưa đo định kỳ | Cần theo dõi | Chỉ chạy pnpm analyze ad-hoc. CKEditor + Ant Design + MUI cùng tồn tại đẩy vendor bundle rất lớn. |
FlyerComponentsV2 làm chuẩn, deprecate v1 và MUI override → giảm CSS trùng lặp.dev.src/api/ và src/providers/flyer/).parent-web khỏi partner-web: chỉ làm khi bundle vượt ngưỡng LCP; hiện shared code còn nhiều nên chưa cần.main.← Danh mục PRD · Kiểm chứng số liệu production · Roadmap chính