| ο»Ώ# AGENTS.md β Pram Project Guide |
|
|
| > This file is for AI agents. Read this first before making any changes. |
|
|
| --- |
|
|
| ## 1. Project Identity |
|
|
| - **Name:** `pram` β AI-powered multi-language test practice platform. |
| - **Repo type:** Turborepo monorepo (Bun workspaces). |
| - **Workspace packages:** `apps/*`, `packages/*`. |
| - **Runtime & package manager:** **Bun** (`packageManager: "bun@1.3.11"`). |
| - Do **NOT** use `pnpm`, `npm`, or `yarn` unless explicitly required by a global tool. |
|
|
| --- |
|
|
| ## 2. Technology Stack |
|
|
| | Layer | Technology | Notes | |
| |-------|------------|-------| |
| | Frontend | React 19 + Vite + TanStack Router | File-based routing. **Not Next.js.** | |
| | Backend | Hono + tRPC | Entry: `apps/server/src/index.ts`. Port 3000. | |
| | DB Engine | PostgreSQL | Managed via Drizzle. | |
| | DB ORM | Drizzle ORM | **Not Prisma.** | |
| | Auth | Better-Auth | Email/password + OAuth. | |
| | UI | shadcn/ui (shared) | Lives in `packages/ui`. Imported as `@pram/ui/components/*`. | |
| | AI | OpenAI-compatible API | User-managed API keys via Settings UI. | |
| | Build | Turborepo + `tsdown` | Server compiled with `tsdown`. | |
|
|
| --- |
|
|
| ## 3. Architecture Overview |
|
|
| ``` |
| pram/ |
| βββ apps/ |
| β βββ web/ # Frontend (Vite, TanStack Router) β Port 5173 |
| β βββ server/ # Backend (Hono, tRPC) β Port 3000 |
| βββ packages/ |
| β βββ ui/ # Shared shadcn/ui components & styles |
| β βββ api/ # tRPC routers & business logic |
| β βββ auth/ # Better-Auth configuration |
| β βββ db/ # Drizzle schema, queries, migrations |
| β βββ ai/ # AI generation: prompts, schemas, agentic pipeline |
| β βββ env/ # Shared environment validation |
| β βββ config/ # Shared TypeScript configs |
| ``` |
|
|
| - **Internal imports** use the `@pram/*` workspace namespace. |
| - Do **NOT** create cross-imports between `apps/*` directly; route through `packages/*`. |
|
|
| --- |
|
|
| ## 4. Essential Commands |
|
|
| Use these exact commands. Do not substitute with `pnpm`/`npm`/`npx` equivalents. |
|
|
| ```bash |
| # Install dependencies |
| bun install |
| |
| # Development |
| bun run dev # Start all apps (web + server) |
| bun run dev:web # Start web app only |
| bun run dev:server # Start server only |
| |
| # Type checking |
| bun run check-types # Check all packages |
| |
| # Database (Drizzle) |
| bun run db:push # Push schema changes to PostgreSQL |
| bun run db:studio # Open Drizzle Studio UI |
| bun run db:migrate # Run migrations |
| bun run db:generate # Generate migration files |
| bun run db:seed # Seed reference data (exam types, etc.) |
| bun run db:start # Start local DB (if configured) |
| bun run db:stop # Stop local DB |
| |
| # Testing |
| bun test # Run all tests |
| bun run turbo test # Run via turbo pipeline |
| |
| # Build |
| bun run build # Build all packages |
| ``` |
|
|
| --- |
|
|
| ## 5. Common Pitfalls β DO NOT |
|
|
| - β **Do not use `pnpm` / `npm` / `yarn`** for package management or running scripts. |
| - β **Do not assume Docker or Docker Compose** exists in this repo. There are no Dockerfiles or compose files. |
| - β **Do not assume Next.js**. This project uses **Vite + TanStack Router**, not Next.js App Router or Pages Router. |
| - β **Do not assume Prisma**. Database layer is **Drizzle ORM**. |
| - β **Do not hardcode API keys** in source code. AI keys are user-managed via the Settings UI. |
| - β **Do not add global CSS** in `apps/web` without checking `packages/ui/src/styles/globals.css` for existing design tokens and CSS variables. |
| - β **Do not skip heading levels**. After `<h1>`, the next heading must be `<h2>`, not `<h3>` (or lower). Never have two `<h1>` on one page. |
| - β **Do not create custom modal/dialog components**. Use shadcn `<Dialog>` from `@pram/ui/components/dialog` β it provides built-in focus trap, escape key, scroll lock, and ARIA. |
| - β **Do not use `any`** in frontend TypeScript. Enforced by eslint `@typescript-eslint/no-explicit-any: "error"` in `apps/web/eslint.config.mjs`. |
|
|
| --- |
|
|
| ## 6. UI / Styling Guidelines |
|
|
| - Design tokens and global styles live in **`packages/ui/src/styles/globals.css`**. |
| - shadcn/ui primitives are shared via **`packages/ui`**. |
| - Import components like this: |
| ```tsx |
| import { Button } from "@pram/ui/components/button"; |
| ``` |
| - To add new shadcn primitives, run from the **project root**: |
| ```bash |
| npx shadcn@latest add accordion dialog -c packages/ui |
| ``` |
| - App-specific blocks (non-shared) should be added directly in `apps/web/src/components/`. |
| - DevTools (TanStack Router, React Query) are **production-gated** via `import.meta.env.DEV` β do not remove the condition. |
| - App-level error boundary is in `__root.tsx` using `<ErrorFallback>` β route-level error boundaries via `errorComponent` route option. |
| - Lazy-loaded route components use the `.lazy.tsx` pattern: route file is thin, heavy component lives in `components/routes/*Page.tsx` wrapped in `Suspense`. |
|
|
| ### Sticky Detail Page Header Pattern |
|
|
| Detail pages with long scrollable content (e.g. `package.$id.index.tsx`, `attempt.$id.tsx`) use a **sticky top header** pattern: |
|
|
| ```tsx |
| <div className="min-h-screen pb-32 bg-[var(--warm-cream)]"> |
| {/* Sticky header */} |
| <div className="sticky top-0 z-20 bg-[var(--warm-cream)]/95 backdrop-blur-sm border-b border-[var(--oat-border)] shadow-sm"> |
| <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-4 pb-4"> |
| {/* Compact breadcrumb β text-xs, truncated title */} |
| {/* Title row (left) + action buttons (right) */} |
| {/* Stats row β text-xs */} |
| </div> |
| </div> |
| {/* Scrollable content */} |
| <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-8"> |
| ... |
| </div> |
| </div> |
| ``` |
|
|
| Rules: |
| - Primary CTA ("Mulai Latihan", "Coba Lagi") and secondary actions (Edit, Share) live in the sticky header β **never only at the bottom** of a long page. |
| - Breadcrumb inside the header is `text-xs` and truncates the current page title to `max-w-[240px]`. |
| - On small screens (`sm:` breakpoint), button text labels hide (`hidden sm:inline`) leaving only icons. |
|
|
| ### Public/Private Toggle Hover Pattern |
|
|
| Any button that toggles visibility between public and private must reveal **intent on hover** (what will happen, not what it currently is): |
|
|
| ```tsx |
| <button |
| onClick={onTogglePublic} |
| aria-label={isPublic ? "Jadikan privat" : "Jadikan publik"} |
| className={`group ... ${isPublic |
| ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--pomegranate-400)]/15 hover:text-[var(--pomegranate-600)]" |
| : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]" |
| }`} |
| > |
| <MaterialIcon name={isPublic ? "public" : "lock"} className="text-xs group-hover:hidden" /> |
| <MaterialIcon name={isPublic ? "lock" : "public"} className="text-xs hidden group-hover:inline" /> |
| <span className="group-hover:hidden">{isPublic ? "Publik" : "Privat"}</span> |
| <span className="hidden group-hover:inline">{isPublic ? "Jadikan Privat" : "Jadikan Publik"}</span> |
| </button> |
| ``` |
|
|
| This pattern is used in `QuestionCard.tsx` and `PackageCard.tsx`. |
|
|
| ### Component Folder Conventions |
|
|
| Large "card" components for list items are extracted into their own folder and file, **not** inlined inside the page component: |
|
|
| | Domain | Card component | Used in | |
| |--------|---------------|---------| |
| | Questions | `apps/web/src/components/bank/QuestionCard.tsx` | `BankPage.tsx` | |
| | Packages | `apps/web/src/components/packages/PackageCard.tsx` | `PackagesPage.tsx` | |
|
|
| When a list item card grows beyond ~40 lines of JSX, extract it. Pass all state through props; the card must be stateless (no data fetching). |
|
|
| ### CalloutCard |
|
|
| `apps/web/src/components/bank/CalloutCard.tsx` is a reusable banner for "N items are private, publish them all" prompts. Use the `label` prop to customise the noun: |
|
|
| ```tsx |
| <CalloutCard count={privateCount} label="soal" onPublishAll={handlePublishAllPrivate} /> |
| <CalloutCard count={privateCount} label="paket" onPublishAll={handlePublishAllPrivate} /> |
| ``` |
|
|
| `count` should come from a **dedicated query with `limit: 1`** (fetches only the `total` field), **not** from the locally accumulated infinite-scroll array β those counts are partial and misleading. |
|
|
| --- |
|
|
| ## 5b. Route Shell & Layout Modes |
|
|
| ### Route Shell System |
|
|
| Every route declares its layout via `staticData` from `apps/web/src/lib/route-shell.ts`. The root layout (`__root.tsx`) reads this to decide which shell to render: |
|
|
| | Shell | When to use | What renders | |
| |-------|-------------|-------------| |
| | `routeShell.app` | Authenticated app pages | Sidebar + `<main id="main-content">` + SkipLink | |
| | `routeShell.public` | Landing, login, auth flows, 404 | Bare `min-h-screen` wrapper, no sidebar | |
| | `routeShell.fullscreen` | Test-taking, immersive UIs | `h-screen overflow-y-auto`, no sidebar | |
|
|
| Usage in a route file: |
| ```tsx |
| import { routeShell } from "@/lib/route-shell"; |
| |
| export const Route = createFileRoute("/my-route")({ |
| staticData: routeShell.app, // or .public / .fullscreen |
| ... |
| }); |
| ``` |
|
|
| **Rules:** |
| - **Always** set `staticData` on every route. Routes without it default to `"app"` shell (sidebar shown). |
| - Auth routes (`/login`, `/forgot-password`, `/verify-email`, `/setup-avatar`) use `routeShell.public`. |
| - Test routes (`/package/$id/take`, `/package/$id/attempt/$attemptId`) use `routeShell.fullscreen`. |
| - Admin routes (`/admin`, `/admin/*`) use `routeShell.fullscreen`. |
|
|
| ### Route Structure Conventions |
|
|
| - **`/`** β Public landing page (`index.tsx` + `index.lazy.tsx`). Redirects logged-in users to `/dashboard`. |
| - **`/dashboard`** β Authenticated home page (`dashboard.tsx`). Redirects unauthenticated users to `/login`. |
| - **`/landing`** β Legacy redirect β `/` (keep for backward compat, do not remove). |
| - **`/$.tsx`** β Catch-all 404 route. Uses `routeShell.public` so no sidebar appears. Renders `<NotFoundPage>`. |
| - **Do NOT** put a `notFoundComponent` in `__root.tsx`. The 404 is handled exclusively by the splat route `$.tsx`. |
|
|
| --- |
|
|
| ## 5c. Frontend Accessibility Conventions |
|
|
| - **Skip link**: Root layout in `__root.tsx` has a hidden `<SkipLink>` that appears on Tab press, linking to `#main-content` on the `<main>` element. |
| - **Icon-only buttons**: Every `<button>` or `<Button>` with only an icon (no visible text) **must** have `aria-label`. The icon element (`<MaterialIcon>`, `<span>`) should NOT have `aria-hidden="true"` unless decorative. |
| - **Heading hierarchy**: Never skip levels (`h1 β h2 β h3`, never `h1 β h3`). Exactly one `<h1>` per page. Heading level corresponds to semantic nesting, not visual size. |
| - **Form labels**: Every `<Input>`, `<select>`, `<textarea>` needs an associated `<Label>` (with `htmlFor` + `id`) OR an `aria-label` attribute. Search inputs, OTP digits, and icon-only filter buttons are common offenders. |
| - **Dialogs**: All modals use the shadcn `<Dialog>` component (from `@pram/ui/components/dialog`), which provides: |
| - Focus trap inside dialog |
| - Escape key to close |
| - Scroll lock on body |
| - Focus restoration to trigger element on close |
| - ARIA `role="dialog"`, `aria-modal`, `aria-labelledby` |
| - **Custom tabs/accordions**: Must have proper ARIA roles (`role="tablist"`, `role="tab"`, `aria-selected`, `tabindex` for roving tabindex) and arrow key handlers. Prefer shadcn `<Tabs>` where possible; use manual ARIA + `handleRovingKeyDown` helper only when tabs have close buttons or other non-standard elements. |
|
|
| --- |
|
|
| ## 7. Environment & Secrets |
|
|
| - Server environment variables are loaded from **`apps/server/.env`**. |
| - Required variables typically include: |
| - `DATABASE_URL` (PostgreSQL connection string) |
| - `BETTER_AUTH_SECRET` |
| - `BETTER_AUTH_URL` |
| - AI provider API keys are **NOT** stored in `.env`. Users configure their own keys via the Settings page in the UI. |
|
|
| --- |
|
|
| ## 8. Database Schema Conventions |
|
|
| - Schema files live in **`packages/db/src/schema/`**. |
| - Use `pgTable` from Drizzle. Primary keys are usually `uuid` with `.defaultRandom()`. |
| - Add indexes for frequently queried foreign keys. |
| - When adding new tables or columns, run `bun run db:push` to sync the local database. |
|
|
| --- |
|
|
| ## 9. AI Generation Context |
|
|
| - AI logic is centralized in **`packages/ai/`**. |
| - Key files: |
| - `src/schemas.ts` β Zod schemas for questions, generation input, and output. |
| - `src/prompts.ts` β Prompt builders for quick mode. |
| - `src/agentic.ts` β Multi-step agentic generation pipeline (passage β validate β questions β self-check). |
| - Adding a new question format requires updates to: |
| 1. `packages/ai/src/schemas.ts` (enum + discriminated union) |
| 2. `packages/ai/src/prompts.ts` (format description) |
| 3. `packages/ai/src/agentic.ts` (format schema string) |
| 4. `packages/api/src/routers/attempt.ts` (answer normalization) |
| 5. `apps/web/src/lib/generate-constants.ts` (format metadata + allowed exams) |
| 6. `apps/web/src/lib/exam-constants.ts` (format string list) |
|
|
| --- |
|
|
| ## 10. Backend Conventions (tRPC / Hono) |
|
|
| ### Error Handling |
| - **Always use English** error messages in tRPC routers. |
| - Use helper functions from `packages/api/src/lib/errors.ts`: |
| ```ts |
| import { throwNotFound, throwForbidden, throwBadRequest } from "@pram/api/lib/errors"; |
| assertOwnership(row, userId, "Question"); // throws NotFound or Forbidden |
| ``` |
| - Never throw raw `new Error("...")` in tRPC routers. |
|
|
| ### Pagination |
| - Use shared schema + helper for all list endpoints: |
| ```ts |
| import { paginationSchema, paginateDefaults } from "@pram/api/lib/pagination"; |
| .input(z.object({ search: z.string().optional(), ...paginationSchema?.shape }).optional()) |
| const { limit, offset } = paginateDefaults(input); |
| ``` |
| - Return shape: `{ items: rows, total }` (or domain-specific name like `{ questions: rows, total }` for legacy compatibility). |
|
|
| ### Authorization (Ownership) |
| - Use `assertOwnership(row, userId, resourceName)` from `packages/api/src/lib/ownership.ts`. |
| - Prefer explicit ownership query + assert over embedding `creatorUserId` in `.where()` for mutations that need a clear 404 vs 403. |
|
|
| ### Visibility (Public/Private) |
| - Use `buildVisibilityCondition(table, userId)` from `packages/api/src/lib/visibility.ts` for list queries. |
| - Default behavior: guests see only public; authenticated users see public + their own private. |
|
|
| ### Logging |
| - Winston logger is available in tRPC context via `ctx.logger`. |
| - Use `logger.info()`, `logger.error()`, etc. for business events and errors. |
| - Hono HTTP request logging is handled automatically by `hono/logger` in `apps/server/src/index.ts`. |
|
|
| ### Router Structure |
| - Shared helpers live in `packages/api/src/lib/`. |
| - Routers live in `packages/api/src/routers/`. |
| - Register new routers in `packages/api/src/routers/index.ts`. |
|
|
| ## 11. Admin Routes & DataTable |
|
|
| ### Admin Route Structure |
| - Admin routes are flat files: `routes/admin.users.tsx`, `routes/admin.credits.tsx`, etc. |
| - Wrapped by `routes/admin.tsx` layout shell (sidebar + admin role check). |
| - All admin tRPC procedures live in `packages/api/src/routers/admin.ts` and use `adminProcedure` (extends `protectedProcedure` with role check). |
| - Mutations use `audit()` helper for audit logging to `adminAuditLog` table. |
|
|
| ### DataTable Component (Design Reference) |
| - Admin table routes should eventually use a `<DataTable<T>>` component from `components/admin/DataTable.tsx`. |
| - API design: generic `<T>`, column defs with `accessorKey`/`accessorFn`/`cell` render prop, `actions` render prop, opt-in pagination, server-side sorting. |
| - Search bars, filter pills, and status pill buttons live **above** the DataTable in the route component (not inside it). |
| - **Do NOT** use DataTable for card-based layouts (e.g., Featured editor's pick) β keep card layouts as-is. |
|
|
| ### Current Migration Status |
| - `admin.users.tsx` β table-based, good candidate for DataTable |
| - `admin.jobs.tsx` β table-based, good candidate |
| - `admin.moderation.tsx` β table-based, good candidate |
| - `admin.credits.tsx` β transaction table, good candidate |
| - `admin.featured.tsx` β card layout, keep as-is |
| - `admin.index.tsx` β dashboard stats cards, keep as-is |
|
|
| --- |
|
|
| ## 11b. Git & Workflow |
|
|
| - Do **NOT** run `git commit`, `git push`, `git rebase`, or force-push unless the user explicitly asks for it. |
| - Do **NOT** create `README.md` or documentation files unless explicitly requested. |
| - Keep changes minimal and focused on the task at hand. |
| - Follow existing code style in the file you are editing. |
|
|
| --- |
|
|
| ## 12. Testing |
|
|
| ### Test Runner |
| - **Bun test** (`bun test`) β built-in, Jest/Vitest compatible API (`describe`, `it`, `expect`). |
| - Turbo task `test` sudah dikonfigurasi. |
|
|
| ### Test Location |
| - Unit tests: `src/__tests__/*.test.ts` di masing-masing package. |
| - Integration tests: `packages/api/src/__tests__/*.integration.test.ts` (pakai PGlite in-memory DB). |
|
|
| ### Integration DB (PGlite) |
| - **PGlite** (`@electric-sql/pglite`) β PostgreSQL WASM in-memory. |
| - Strategy: `mock.module("@pram/db")` intercept DB saat dynamic import untuk inject PGlite-based drizzle instance. |
| - Setup helper: `packages/api/src/__tests__/test-setup.ts` β skema 14 tabel via raw SQL + seed data. |
| - Schema diimport dari `../../../db/src/schema` (langsung, bukan via `@pram/db`) untuk hindari env validation side-effect. |
| - Env vars di-mock via `mock.module("@pram/env/server")` sebelum dynamic import. |
|
|
| ### tRPC Router Testing |
| - Gunakan `router.createCaller({ session, auth })` untuk memanggil procedure. |
| - Protected procedure: `session: { user: { id }, expiresAt: new Date() }`. |
| - Public procedure: `session: null`. |
|
|
| ### Known Limitations |
| - **Rate limiter** (`checkRateLimit` di `attempt.ts`) pakai in-memory `Map`. Gunakan user ID berbeda per test atau `Bun.sleep()` untuk menghindari rate limit blocker. |
| - **Timer validation** (`finish`) butuh β₯5 detik elapsed sejak `start` β pakai `Bun.sleep()` + `{ timeout: 30000 }` pada `it()`. |
| - **Env vars Wajib** untuk di-mock saat integration test: `DATABASE_URL`, `BETTER_AUTH_SECRET` (β₯32 chars), `BETTER_AUTH_URL`, `CORS_ORIGIN`, `API_KEY_ENCRYPTION_KEY` (β₯32 chars), `REDIS_URL`, `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`. |
|
|
| ### TDD Convention |
| - TDD: tulis test dulu, lihat fail, baru implementasi. |
| - Untuk existing code yang belum ada test: tulis test yang verifikasi behavior saat ini. |
| - Fungsi internal yang perlu di-test secara unit harus di-`export`. |
|
|
| --- |
|
|
| _Last updated: 2026-05-25_ |
|
|