Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# AGENTS.md

## Project at a glance

Open-source, prerequisite-graph education platform. pnpm monorepo with three
workspace packages:

| Package | Path | What it is |
|---|---|---|
| `app` | `app/` | React 19 · TanStack Start (SSR) · TanStack Router · shadcn/ui · Tailwind 4 · Vite |
| `api` | `api/` | Hono on Cloudflare Workers · OpenAPI auto-generated · Zod validation |
| `@bluelearn/schemas` | `packages/schemas/` | Shared Zod schemas (requests, responses, enums) consumed by both app and api |

Database: Supabase (Postgres 17, GoTrue Auth, RLS). Search: Typesense (separate service).

## Commands

All run from the repo root via pnpm.

```bash
pnpm install # install all deps
pnpm dev # app (port 3000) + api (port 8787) in parallel
pnpm dev:app # frontend only (Vite)
pnpm dev:api # API only (wrangler dev + tsc --watch)

pnpm build # build all packages
pnpm typecheck # typecheck all packages
pnpm lint # lint all packages
pnpm format # prettier --write
pnpm format:check # prettier --check (CI uses this)
pnpm test # test all packages (Vitest)
```

### Single-package commands

```bash
pnpm --filter app typecheck
pnpm --filter app lint
pnpm --filter app test
pnpm --filter app build

pnpm --filter api typecheck
pnpm --filter api lint
pnpm --filter api test
pnpm --filter api exec vitest run # run API tests directly

pnpm --filter @bluelearn/schemas typecheck
pnpm --filter @bluelearn/schemas lint
```

### Database

```bash
pnpm supabase:start # requires Docker
pnpm supabase:stop
pnpm supabase:reset # drop + recreate + reseed
pnpm supabase:types # regenerate api/src/database.types.ts (local)
pnpm supabase:types:remote # regenerate from hosted Supabase
```

### Deployment

```bash
pnpm api:deploy # wrangler deploy
```

### CI order (per package)

CI runs these jobs in parallel across packages:
- **format**: `pnpm format:check`
- **app**: typecheck → lint → test → build
- **api**: typecheck → lint → dry-deploy (`wrangler deploy --dry-run`)
- **schemas**: typecheck → lint

## Environment setup

Two env files, different prefixes:

1. Copy `api/.dev.vars.example` → `api/.dev.vars` (Supabase URL, keys, Typesense, APP_URL)
2. Copy `app/.env.example` → `app/.env` (VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_BASE)
3. Fill values from `pnpm supabase:start` output.

**Critical**: Frontend env vars use `VITE_` prefix; API vars are plain. They are NOT interchangeable.

## Architecture

```
browser → app (Vite SSR, port 3000) → api (Wrangler, port 8787) → Supabase (Postgres + Auth)
```

- **app/** never talks to Supabase directly. All data flows through api/.
- **api/** is stateless; state lives in Postgres. Rate limiter uses a Durable Object (SQLite-backed), falls back to in-memory in dev/test.
- **Type safety**: `api/src/index.ts` exports `AppType`. The frontend imports it via `hc<AppType>()` for fully typed HTTP calls (request + response + validated body).

### API middleware stack

Applied in order: CORS → global rate limit (READ) → `supabaseMiddleware()` → per-route auth (`requireUser`) → per-route rate limits → `validate()` (Zod).

Routes mounted before `supabaseMiddleware()` (e.g., `/avatar`) are unauthenticated.

### Key files

| File | Why it matters |
|---|---|
| `api/src/index.ts` | AppType export, route mounting, cron entry point |
| `api/src/types.ts` | Bindings type (env vars) |
| `api/src/middleware/auth.middleware.ts` | `supabaseMiddleware`, `requireUser` |
| `api/src/middleware/rateLimits.ts` | Rate limit presets (CREATE, READ, SEARCH, etc.) |
| `app/src/lib/api/apiClient.ts` | Hono typed client with auto auth headers |
| `app/src/lib/authContext.tsx` | `useAuth()`, `useRequireRole()` |
| `packages/schemas/src/` | All shared Zod schemas (guides, objectives, subjects, etc.) |
| `api/src/database.types.ts` | Generated Postgres types — regenerate after schema changes |

### Cron triggers

- Every 5 min: `assemblePendingPanels`, `sweepExpiredReviewSeats`
- Every 12 hours: `promoteAllCanonicals`

## Conventions

### Branching and commits

- Branch: `<type>/<short-kebab>` (e.g., `feat/concept-prefetch`)
- PR title: `<type>(<scope>): <description>` where type is `feat|fix|docs|refactor|chore|test|perf|ci`
- Commits: Conventional Commits style, not enforced. Must include `Signed-off-by:` (`git commit -s`).

### Code style

- **Prettier**: double quotes, semicolons, 2-space indent, trailing commas (es5), LF line endings.
- **Path alias**: `@/` maps to `app/src/`. Always use `@/lib/x`, never `../../../lib/x`.
- **No `// @ts-ignore`** without a comment explaining why.
- **No drive-by reformatting** in bug-fix PRs.
- **Tailwind classes**: Use `cn()` from `app/src/lib/utils.ts` for conditional classes. Prettier sorts Tailwind via plugin.

### Frontend routing

File-based with TanStack Router in `app/src/routes/`. Layout routes use `<Outlet>`. Routes marked `ssr: false` are client-only (e.g., review). Loaders use `loader: async ({ abortController }) => {...}`.

### API route pattern

Each resource is a Hono router in `api/src/routes/`. Pattern:
```typescript
export const fooRouter = new Hono<HonoEnv>()
.get("/", describeRoute({...}), validate("query", schema), handler)
.post("/", describeRoute({...}), requireUser, rateLimitMiddleware({...}), validate("json", schema), handler);
```

Services live in `api/src/services/*.service.ts`, throw `ServiceError` for HTTP failures.

### Database

- Migrations: timestamp-prefixed `.sql` files in `supabase/migrations/`.
- Complex operations are PostgreSQL RPCs (defined in migrations, called via Supabase client).
- RLS policies enforce per-user access. API uses per-request Supabase client with the user's JWT.
- After schema changes: `pnpm supabase:types` to regenerate `api/src/database.types.ts`.

### Tests

- **App**: Vitest with `--passWithNoTests`. Tests in `app/src/lib/__tests__/`.
- **API**: Vitest in `api/tests/`. Tests create real users via `admin.auth.admin.createUser()` and run against the local Supabase DB. Factory helpers in `api/tests/factories/`.
- **OpenAPI validation**: `api/tests/openapi.ts` validates responses against generated spec.

## Gotchas

- Docker must be running for `supabase start`.
- `api/src/database.types.ts` is generated, not hand-written. Regenerate after any migration.
- API CORS only allows `APP_URL` from `.dev.vars`. Wrong value = silent CORS failures.
- The `RATE_LIMITER` Durable Object binding is optional in dev. Tests run without it and use in-memory counters.
- The app's `typecheck` script runs `pnpm --filter api build` first (builds API declarations needed for `AppType` import). This is intentional.
- `app/src/routeTree.gen.ts` is auto-generated by TanStack Router. Never edit it manually.
- `api/src/database.types.ts` and `app/src/routeTree.gen.ts` are both in `.prettierignore`.

## Instruction sources

- `CONTRIBUTING.md` — full contributor guide (setup, PR process, review expectations)
- `.github/copilot-instructions.md` — companion file with similar guidance for Copilot
- `docs/architecture.md` — system diagram and boundary rationale
- `docs/monorepo.md` — why one repo instead of three
- `.github/workflows/ci.yml` — CI pipeline definition
90 changes: 86 additions & 4 deletions api/src/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,54 @@ export type Json =
| Json[]

export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.5"
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
extensions?: Json
operationName?: string
query?: string
variables?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
disclaimers: {
Row: {
description: string | null
id: string
label: string
slug: string
}
Insert: {
description?: string | null
id?: string
label: string
slug: string
}
Update: {
description?: string | null
id?: string
label?: string
slug?: string
}
Relationships: []
}
guide_bases: {
Row: {
canonical_guide_id: string | null
Expand Down Expand Up @@ -72,6 +113,43 @@ export type Database = {
},
]
}
guide_disclaimers: {
Row: {
disclaimer_id: string
guide_base_id: string
}
Insert: {
disclaimer_id: string
guide_base_id: string
}
Update: {
disclaimer_id?: string
guide_base_id?: string
}
Relationships: [
{
foreignKeyName: "guide_disclaimers_disclaimer_id_fkey"
columns: ["disclaimer_id"]
isOneToOne: false
referencedRelation: "disclaimers"
referencedColumns: ["id"]
},
{
foreignKeyName: "guide_disclaimers_guide_base_id_fkey"
columns: ["guide_base_id"]
isOneToOne: false
referencedRelation: "guide_bases"
referencedColumns: ["id"]
},
{
foreignKeyName: "guide_disclaimers_guide_base_id_fkey"
columns: ["guide_base_id"]
isOneToOne: false
referencedRelation: "published_guides"
referencedColumns: ["id"]
},
]
}
guide_edges: {
Row: {
created_at: string
Expand Down Expand Up @@ -1410,6 +1488,9 @@ export type CompositeTypes<
: never

export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
app_role: ["verifier", "moderator", "curator", "admin", "official"],
Expand Down Expand Up @@ -1451,3 +1532,4 @@ export const Constants = {
},
},
} as const

2 changes: 2 additions & 0 deletions api/src/routes/guides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ export const guideRevisionsRouter = new Hono<HonoEnv>()
prerequisites,
todos,
revised_from_case_id,
disclaimers,
} = await getRevision(c.get("supabase"), c.req.valid("param").id);
return c.json({
revision,
Expand All @@ -578,6 +579,7 @@ export const guideRevisionsRouter = new Hono<HonoEnv>()
prerequisites,
todos,
revised_from_case_id,
disclaimers,
});
}
)
Expand Down
68 changes: 68 additions & 0 deletions api/src/services/disclaimer.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DisclaimerSlug } from "@bluelearn/schemas";
import type { Database } from "../database.types";
import { ServiceError } from "../lib/service-error";

type DB = SupabaseClient<Database>;

// Load disclaimer slugs for a guide base.
export async function loadDisclaimers(
supabase: DB,
baseId: string
): Promise<DisclaimerSlug[]> {
const { data, error } = await supabase
.from("guide_disclaimers")
.select("disclaimers!inner(slug)")
.eq("guide_base_id", baseId);

if (error) {
console.error(error);
throw new ServiceError("Failed to load disclaimers", 500);
}
return (data ?? [])
.map((r) => r.disclaimers?.slug)
.filter((s): s is DisclaimerSlug => s != null);
}

// Replace a guide base's disclaimer set.
export async function replaceDisclaimers(
supabase: DB,
baseId: string,
slugs: DisclaimerSlug[]
) {
const unique = [...new Set(slugs)];

const { error: delError } = await supabase
.from("guide_disclaimers")
.delete()
.eq("guide_base_id", baseId);
if (delError) {
console.error(delError);
throw new ServiceError("Unable to update disclaimers", 400);
}

if (unique.length === 0) return;

const { data: disclaimerRows, error: lookupError } = await supabase
.from("disclaimers")
.select("id, slug")
.in("slug", unique);
if (lookupError) {
console.error(lookupError);
throw new ServiceError("Failed to resolve disclaimers", 500);
}
if ((disclaimerRows ?? []).length !== unique.length) {
throw new ServiceError("Unknown disclaimer slug", 400);
}

const { error: insError } = await supabase.from("guide_disclaimers").insert(
disclaimerRows!.map((d) => ({
guide_base_id: baseId,
disclaimer_id: d.id,
}))
);
if (insError) {
console.error(insError);
throw new ServiceError("Unable to update disclaimers", 400);
}
}
Loading
Loading