Imported from jacksaybin68/Vietjet (
vietjetsim-main/AGENTS.md). Install upstream withnpx skills add jacksaybin68/Vietjet --skill vietjetsim-main. Copyright stays with the author.
AGENTS.md
Repository memory for VietjetSim (Next.js 15 / React 19 / TypeScript / Tailwind / Neon Postgres).
Commands
npm run dev # next dev --turbo -p 4028
npm run build # next build (runs its own TS type check — a tsc error fails the build)
npm run type-check # tsc --noEmit
npm test # vitest run
npm run lint # eslint . (NOT `next lint` — that is removed in Next 15)
npm run lint:fix # eslint . --fix
Lint/format rules live in eslint.config.mjs (flat config, prettier printWidth 100,
single quotes, trailingComma: 'es5'). Run npx eslint <file> --fix before finishing.
Lint is NOT part of npm run build: Next 15/16 removed the eslint key from
next.config.mjs (setting it logs "Unrecognized key(s) in object: 'eslint'"), and CI
enforces lint as its own step. Only typescript.ignoreBuildErrors: false remains as a
build-time gate, so always run npm run lint in addition to the build.
Architecture
Feature modules under src/features/<name>/ own types/, constants.ts, services/,
and an index.ts barrel. App routes in src/app/ should consume those services rather
than calling fetch('/api/...') directly, so mapping logic and types stay in one place.
Shared infrastructure lives in src/shared/ (services/apiClient.ts, constants/,
components/ui).
Neon Postgres via src/lib/neon.ts (sql template tag) is the only datastore. When
DATABASE_URL is unset, that module falls back to an in-memory mock for local dev/CI —
tests rely on it, so keep the fallback intact. There is no Supabase dependency: the auth
layer is custom JWT (src/lib/auth.ts) and data isolation comes from user_id-scoped
queries plus RBAC guards, not database-level policies.
Apply migrations/*.sql in filename order; they are the only schema source. 013
wires the bookings.booking_code default and 014 seeds airports, flights and the
README demo accounts, so a freshly migrated database is usable without manual inserts.
014 is idempotent (re-runs keep counts stable via ON CONFLICT) and dates its flights
relative to CURRENT_DATE, so date-filtered flight search only finds them on the
following day.
To run the app against a local database without a Neon account, note that
@neondatabase/serverless speaks Neon's HTTP /sql protocol, not the Postgres wire
protocol — raw DATABASE_URL=postgresql://localhost/... fails with
Failed to parse URL from https://api.<host>/sql. Point DATABASE_URL at a
<db>.<project>.neon.tech-style host and run a small HTTP proxy that translates
{query, params} calls to real Postgres, or use a real Neon branch.
Critical conventions
- Use
apiRequestfrom@/shared/servicesfor app API calls. It sendscredentials: 'include'and automatically attaches the CSRF header on non-GET methods. Rawfetchmutations silently omit CSRF and will be rejected once a route enforces it. UsegetApiErrorMessage(error, fallback)for toasts. - CSRF is enforced on every mutating route.
verifyAuthRequestandverifyAdminRequestrunvalidateCsrfOrRejectfor non-GET methods, so new mutating handlers that use either helper are covered automatically. Handlers that parse cookies themselves must callvalidateCsrfOrRejectexplicitly.middleware.tsseeds thecsrf_tokencookie for cookieless sessions, sogetCsrfHeaders()always has a value to echo back. When adding enforcement, migrate the client toapiRequestin the same change or the UI breaks with 403. verifyAdminRequestreturns a discriminated union. Failure always carriesresponse, soconst { error, response } = await verifyAdminRequest(...); if (error) return response;narrows correctly and avoids returningundefinedfrom a handler.- All state-changing admin APIs must be gated by
verifyAdminRequest; middleware (middleware.ts) also blocks non-admins from/quan-triand/api/quan-tri. - Public routes/APIs are declared in
src/lib/route-access.ts, whichmiddleware.tsimports (src/lib/route-access.test.tspins the classification).isPublicApiRoutelists only endpoints that must answer without a session (flight search, booking-code check-in, public bank config); anything else is treated as private, so don't add an entry without confirming the handler is genuinely anonymous. - Filesystem routes (
/api/editor/files) must stay admin-only and reject paths outside the project root plus secret-bearing files (.env*,*.pem,*.key,.git). - Never trust identity from request bodies or headers (
x-user-id). Resolve the caller from the signedaccess_tokenviagetToken/verifyAuthRequest. - Gate privileged checks with
isAdminRole(role), notrole === 'admin', so the comparison survives future role additions. - Chat conversation access must go through
userOwnsConversation(conversationId, userId)for non-admins; do not fetch all conversations just to check ownership. - Paginated handlers use
src/lib/pagination.ts(parsePaginationParams,getOffset,getPaginationMeta) rather than rawparseInt(searchParams.get('page')). The raw idiom turns malformed input intoNaNand skips the 100-row limit cap. sql.transaction([...])cannot reference another statement's result. The Neon batch API runs each statement independently, so interpolatingqueryA.idintoqueryBpassesundefined. To write a parent and its children in one round trip, use a data-modifying CTE and have the child insertsSELECT nb.id FROM new_booking nb(seecreateBooking). Bulk rows go in viajsonb_to_recordset, which needs the::jsonbcast and a column list whose types match the target columns (dob date, nottext— there is no implicit text→date cast in a recordset definition).json_agg(...) FILTER (WHERE ...)returns SQLNULL, not[], when nothing matches. Map it withArray.isArray(x) ? x : []before indexing;x[0]throws on a booking with no passengers or payments.- Every booking-scoped route must verify ownership, not just authentication.
booking.user_id === user.userId(orisAdminRole(user.role)) before reading or mutating./api/checkin(POST) and/api/checkin/status/[bookingId]previously accepted any booking id / booking code, letting a signed-in user read or check in someone else's reservation. The status route requires a session for the same reason. - 2FA is enforced at login, not at the API layer.
user_2farows whoseis_enabledis false are incomplete enrollments and must never block a login;/api/xac-thuc/dang-nhapanswers 401 withrequires2FA: truewhen a code is needed. Backup codes are stored as SHA-256 digests inbackup_codesand spent with a conditional UPDATE, so they are single-use even under concurrent requests. Usesrc/lib/two-factor.tsfor TOTP and code handling rather than callingotplibdirectly. - Login identity is
session_id, notuser_sessions.is_current.is_currentdescribes the viewer, so the sessions API computes it by comparing each row against thesession_idcookie.user_sessionsandlogin_historylive insrc/lib/security-db.ts; deletes are always scoped byuser_idso a foreign session id is a silent no-op. - The README demo passwords only apply to a fresh database.
014_seed_demo_data.sqlseedsuser@vietjetsim.vn/admin@vietjetsim.vnwithON CONFLICT (email) DO NOTHING, so on a database where those rows already exist with other hashes the migration is a no-op and the documented logins fail.npm run db:seed-demo(scripts/seed-demo-accounts.cjs) resets both accounts to the README credentials and their intended roles. Keep the README table and that script'sDEMO_ACCOUNTSin sync; it writes to whateverDATABASE_URLpoints at.
Testing
Vitest, tests in src/test/ (src/test/setup.ts is the setup file). Route handlers are
tested by importing them directly and passing a real NextRequest with a real JWT from
signAccessToken in @/lib/auth. @/lib/neon is mocked there. Prefer this over
mocking business logic.
setup.ts stubs next/navigation (useRouter/usePathname/useSearchParams/useParams).
Components are rendered outside an <AppRouterContext>, so any component calling
useRouter throws "invariant expected app router to be mounted" without it. Add to that
mock rather than wrapping individual tests in a router provider.
npm run test:smoke (scripts/smoke-test.cjs) is a dependency-free HTTP smoke test
against a running server; CI runs it in the smoke job on port 4028 with no
DATABASE_URL, so it exercises the mock DB path. It asserts routing, redirects, the CSRF
handshake and auth guards, not happy-path writes — the mock DB cannot register users, so
persistence flows are out of scope. Point it elsewhere with SMOKE_BASE_URL.
UI design system
Brand red is #EC2029 (hover #D91A21, dark #6F0000); the CTA/action yellow is
#FFDD00 with the deeper #F9A51A/#FBB612 accents. Theme values live in
tailwind.config.js and src/styles/tailwind.css. Keep pages on these tokens — a
past palette (#ED1D23, #E30613, #FFD400, #FFC400) was removed, so reintroducing
one of those hexes is a regression, not a neutral choice.
vj-menubar styles the red uppercase nav row and vj-cta the gold pill button; prefer
those utilities over restyling a bespoke button. Most pages render <Header /> and
<Footer /> from @/shared/components/navigation themselves (only /dang-nhap,
/editor, /quan-tri are intentionally standalone), so a new page should add both.
/hanh-ly is a redirect to /dich-vu?service=baggage, and /dich-vu reads the
service query param (baggage, meal, seat, insurance, priority, lounge) to
preselect a panel. Link services as /dich-vu?service=<id> rather than as subpaths like
/dich-vu/hanh-ly, which do not exist and 404.
Tailwind gotchas that caused real regressions
[var(--x)]/Nsilently compiles to nothing. Tailwind 3.4's/opacitymodifier cannot resolve a plain CSS variable, sobg-[var(--vj-red)]/10produces no rule at all — no error, no warning. Use channel variables and wrap inrgb():bg-[rgb(var(--vj-red-rgb))]/10. Channel tokens (--vj-red-rgb: 236 32 41) live in:rootintailwind.css; add one whenever you add a translucent tint.- Only
src/styles/tailwind.cssis imported (bysrc/app/layout.tsx). A stylesheet undersrc/styles/that nothing imports is dead code — dark mode once shipped a second[data-theme="dark"]mechanism that way whileThemeContexttoggles adarkclass.darkMode: 'class'with a.darkselector is the only supported mechanism. - Use the token for brand red:
hover:bg-primary-dark/var(--primary-dark)(#D91A21), not a bespoke#C41017/#D0021B. One-off hexes for a specific UI accent are fine, but a button hover is not a place to invent a shade. - Page roots share the canvas token —
min-h-screen bg-[var(--surface)]for content pages,bg-[var(--background)]where the page must stay white in light mode. Avoidbg-gray-50/bg-stone-50/raw hex, which ignore dark mode.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.