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.

# 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:
    import { Button } from "@pram/ui/components/button";
    
  • To add new shadcn primitives, run from the project root:
    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:

<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):

<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:

<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:

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:
    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:
    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