cpns / AGENTS.md
pramudya-yb
Rename project from Labas to Pram
2b62b0c
|
Raw
History Blame Contribute Delete
18.3 kB
ο»Ώ# 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_