feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -0,0 +1,531 @@
---
name: Lesson AI Features
overview: Add credit-based "Listen to Lesson" (TTS) and "Summarize" (LLM) features to lessons, with a dedicated AI credit system, Polar.sh credit purchases, and a teacher-managed AI tab in edit mode.
todos:
- id: openai-dep
content: Install openai package in apps/api and add OPENAI_API_KEY to env schema
status: pending
- id: db-tables
content: Add lesson_ai_cache, organization_ai_usage, organization_ai_credit_purchase tables to schema.ts and generate migration
status: pending
- id: db-queries
content: Create query functions for AI cache, AI usage tracking, and AI credit balance computation
status: pending
- id: ai-credits-constants
content: Create AI credit costs and per-plan limits in packages/utils/src/plans/ai-credits.ts
status: pending
- id: error-codes
content: Add AI-related error codes to ErrorCodes
status: pending
- id: ai-credit-service
content: Create ai-credits.ts service for balance checks, deduction, and Polar purchase handling
status: pending
- id: ai-service
content: Create lesson-ai.ts service with summarizeLesson and generateLessonAudio
status: pending
- id: validation
content: Add Zod schemas for AI endpoints (summarize, audio, cost-estimate, credit-balance)
status: pending
- id: api-routes
content: Add AI lesson routes and AI credit routes to the API
status: pending
- id: polar-webhook
content: Extend Polar webhook to handle AI credit pack purchases
status: pending
- id: api-build
content: Build API and verify RPC types propagate
status: pending
- id: fe-types
content: Add request/response types to course/utils/types.ts
status: pending
- id: fe-api-methods
content: Add AI methods to LessonApi class and create AiCreditsApi class
status: pending
- id: fe-ai-tab
content: Create lesson AI tab component for edit mode (generate/regenerate TTS and summaries)
status: pending
- id: fe-student-view
content: Create student-facing Listen and Summary components for view mode
status: pending
- id: fe-credit-ui
content: Add AI credit balance display and buy-credits flow in settings
status: pending
- id: fe-translations
content: Add translation keys for all AI feature strings
status: pending
- id: fe-integrate
content: Wire everything into lesson.svelte, export from barrels, final integration
status: pending
isProject: false
---
# Lesson AI Features: Listen to Lesson and Summarize
## Architecture
```mermaid
flowchart TD
subgraph teacherView [Teacher - Edit Mode]
AITab["AI tab in lesson editor"]
CostPreview["Credit cost per lesson"]
GenerateBtn["Generate / Regenerate buttons"]
end
subgraph studentView [Student - View Mode]
ListenBtn["Listen to lesson button"]
SummaryPanel["AI Summary panel"]
AudioPlayer["Audio player"]
end
subgraph creditSystem [AI Credit System]
BalanceCheck["Balance check"]
PlanCredits["Plan monthly credits"]
PurchasedCredits["Purchased credit packs"]
PolarCheckout["Polar.sh checkout"]
end
subgraph api [API - Hono]
SummarizeRoute["POST lesson/:id/summarize"]
AudioRoute["POST lesson/:id/audio"]
CostRoute["GET lesson/:id/ai-cost"]
BalanceRoute["GET org/ai-credits"]
PurchaseRoute["Polar webhook"]
AIService["lesson-ai.ts"]
CreditService["ai-credits.ts"]
end
subgraph storage [Storage]
Cache["lesson_ai_cache"]
UsageTable["organization_ai_usage"]
PurchaseTable["organization_ai_credit_purchase"]
S3["S3 audio files"]
end
subgraph openai [OpenAI]
ChatAPI["GPT-4o-mini"]
TTSAPI["tts-1"]
end
AITab --> CostPreview
AITab --> GenerateBtn
GenerateBtn -->|RPC| SummarizeRoute
GenerateBtn -->|RPC| AudioRoute
ListenBtn --> AudioPlayer
studentView -->|"reads cached"| Cache
SummarizeRoute --> CreditService
AudioRoute --> CreditService
CreditService --> BalanceCheck
BalanceCheck --> PlanCredits
BalanceCheck --> PurchasedCredits
PolarCheckout -->|webhook| PurchaseRoute
PurchaseRoute --> PurchaseTable
CreditService --> AIService
AIService --> Cache
AIService --> UsageTable
AIService -->|summary| ChatAPI
AIService -->|audio| TTSAPI
AIService -->|upload| S3
```
## Credit System Design
### Credit Costs
| Action | Credits | Approx. OpenAI cost |
|--------|---------|---------------------|
| Generate summary | 1 | ~$0.0003 |
| Generate TTS audio | 5 | ~$0.05-0.08 |
| Regenerate (same costs) | same | same |
### Per-Plan Monthly AI Credits
| Plan | Monthly AI Credits |
|------|-------------------|
| BASIC (Free) | 5 |
| EARLY_ADOPTER | 50 |
| ENTERPRISE | 500 |
### Purchased Credits
- One-time credit packs via Polar.sh (e.g., 50 credits, 200 credits)
- Purchased credits never expire
- Plan credits are consumed first each month; purchased credits are used only when plan credits run out
- New Polar products need to be created for credit packs
### Balance Computation
```
planCreditsRemaining = planMonthlyLimit - SUM(ai_usage WHERE source='plan' AND this billing month)
purchasedCreditsRemaining = SUM(ai_credit_purchase.credits) - SUM(ai_usage WHERE source='purchased')
totalAvailable = planCreditsRemaining + purchasedCreditsRemaining
```
When consuming credits: deduct from plan credits first. If plan credits are exhausted, deduct from purchased pool. The `source` column on `organization_ai_usage` tracks which pool was charged.
---
## Phase 1: Backend Infrastructure
### 1a. OpenAI dependency and env config
- Install `openai` (latest) in [apps/api/package.json](apps/api/package.json)
- Add `OPENAI_API_KEY` (optional string) to the Zod env schema in [apps/api/src/config/env.ts](apps/api/src/config/env.ts)
### 1b. Database tables
Add to [packages/db/src/schema.ts](packages/db/src/schema.ts) and generate a migration.
**Enum:**
```typescript
export const lessonAiFeatureType = pgEnum('LESSON_AI_FEATURE_TYPE', ['summary', 'audio']);
export const aiCreditSource = pgEnum('AI_CREDIT_SOURCE', ['plan', 'purchased']);
```
**`lesson_ai_cache`** -- stores generated summaries and audio per lesson+locale:
```typescript
export const lessonAiCache = pgTable('lesson_ai_cache', {
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }),
locale: locale().default('en').notNull(),
featureType: lessonAiFeatureType('feature_type').notNull(),
content: text(), // markdown summary
audioAssetKey: text('audio_asset_key'), // S3 key for TTS audio
contentHash: text('content_hash').notNull(), // hash of source lesson HTML
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
}, (table) => [
unique('unique_lesson_locale_feature').on(table.lessonId, table.locale, table.featureType)
]);
```
**`organization_ai_usage`** -- tracks each AI generation event:
```typescript
export const organizationAiUsage = pgTable('organization_ai_usage', {
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
organizationId: uuid('organization_id').notNull().references(() => organization.id),
lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }),
featureType: lessonAiFeatureType('feature_type').notNull(),
creditsConsumed: integer('credits_consumed').default(0).notNull(),
source: aiCreditSource('source').notNull(), // 'plan' or 'purchased'
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`).notNull()
}, (table) => [
index('idx_org_ai_usage_org_created').on(table.organizationId, table.createdAt)
]);
```
**`organization_ai_credit_purchase`** -- tracks Polar credit pack purchases:
```typescript
export const organizationAiCreditPurchase = pgTable('organization_ai_credit_purchase', {
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
organizationId: uuid('organization_id').notNull().references(() => organization.id),
credits: integer('credits').notNull(),
polarOrderId: text('polar_order_id'),
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`).notNull()
});
```
### 1c. Database queries
New file: `packages/db/src/queries/organization/ai-credits.ts`
- `getAiCreditsUsedThisMonth(orgId, source)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='plan'` AND `createdAt >= billingPeriodStart`
- `getTotalPurchasedCredits(orgId)` -- SUM of `credits` from `organization_ai_credit_purchase`
- `getTotalPurchasedCreditsUsed(orgId)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='purchased'`
- `recordAiUsage(data)` -- insert into `organization_ai_usage`
- `recordAiCreditPurchase(data)` -- insert into `organization_ai_credit_purchase`
New file: `packages/db/src/queries/lesson/ai-cache.ts`
- `getLessonAiCache(lessonId, locale, featureType)` -- get cached entry
- `upsertLessonAiCache(data)` -- insert or update on conflict
### 1d. AI credit constants
New file: [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts)
```typescript
export const AI_FEATURE_CREDIT_COST = {
summary: 1,
audio: 5,
} as const;
export const AI_CREDITS_BY_PLAN: Record<string, { monthlyCredits: number }> = {
[PLAN.BASIC]: { monthlyCredits: 5 },
[PLAN.EARLY_ADOPTER]: { monthlyCredits: 50 },
[PLAN.ENTERPRISE]: { monthlyCredits: 500 },
};
```
Re-export from `packages/utils/src/plans/index.ts`.
### 1e. Error codes
Add to [packages/utils/src/constants/error-codes.ts](packages/utils/src/constants/error-codes.ts):
```typescript
AI_CREDITS_EXHAUSTED: 'AI_CREDITS_EXHAUSTED',
AI_FEATURE_UNAVAILABLE: 'AI_FEATURE_UNAVAILABLE',
AI_GENERATION_FAILED: 'AI_GENERATION_FAILED',
```
### 1f. AI credit service
New file: `apps/api/src/services/organization/ai-credits.ts`
- `getAiCreditBalance(orgId)` -- computes `planCreditsRemaining`, `purchasedCreditsRemaining`, `totalAvailable`, `monthlyLimit`, and returns the full balance object
- `assertAiCreditsAvailable(orgId, featureType)` -- checks totalAvailable >= cost, throws `AI_CREDITS_EXHAUSTED` (429) if insufficient
- `deductAiCredits(orgId, lessonId, featureType)` -- determines source (plan first, then purchased), inserts usage row
- `addPurchasedCredits(orgId, credits, polarOrderId)` -- inserts into `organization_ai_credit_purchase`
### 1g. Lesson AI service
New file: `apps/api/src/services/lesson/lesson-ai.ts`
**`summarizeLesson(lessonId, locale, orgId)`**
1. Fetch lesson content from `lesson_language` for locale
2. Strip HTML to plain text, compute content hash
3. Check `lesson_ai_cache` -- if hash matches, return cached summary
4. Call `assertAiCreditsAvailable(orgId, 'summary')`
5. Call OpenAI Chat Completions (`gpt-4o-mini`) with summarization prompt
6. Upsert `lesson_ai_cache` with summary + hash
7. Call `deductAiCredits(orgId, lessonId, 'summary')`
8. Return summary
**`generateLessonAudio(lessonId, locale, orgId)`**
1. Fetch lesson content, strip HTML, compute hash
2. Check cache -- if hash matches and audio key exists, return presigned URL
3. Call `assertAiCreditsAvailable(orgId, 'audio')`
4. Chunk text into segments under 4096 chars
5. Call OpenAI TTS (`tts-1`, voice `alloy`) for each chunk
6. Concatenate audio buffers, upload to S3
7. Upsert cache with audio key + hash
8. Call `deductAiCredits(orgId, lessonId, 'audio')`
9. Return presigned URL
**`estimateLessonAiCost(lessonId, locale)`**
1. Fetch lesson content, check what's already cached
2. Return `{ summaryCost, audioCost, totalCost, isStale }` -- 0 if already cached and fresh
**`getLessonAiStatus(lessonId, locale)`**
1. Check cache for both summary and audio
2. Return `{ hasSummary, hasAudio, isSummaryStale, isAudioStale, summary?, audioUrl? }`
Shared util: `stripHtmlToText(html)` using regex or a lightweight lib.
### 1h. API routes
**Extend lesson router** in [apps/api/src/routes/course/lesson.ts](apps/api/src/routes/course/lesson.ts):
- `POST /:lessonId/summarize` -- auth + courseMember, role 1/2 only (teacher/admin), calls `summarizeLesson`
- `POST /:lessonId/audio` -- auth + courseMember, role 1/2 only, calls `generateLessonAudio`
- `GET /:lessonId/ai-status` -- auth + courseMember, calls `getLessonAiStatus` (used by both edit and view mode)
- `GET /:lessonId/ai-cost` -- auth + courseMember, role 1/2 only, calls `estimateLessonAiCost`
**New org AI credit routes** (add to [apps/api/src/routes/organization/organization.ts](apps/api/src/routes/organization/organization.ts) or a new `ai-credits` sub-router):
- `GET /ai-credits` -- auth, returns `getAiCreditBalance(orgId)`
### 1i. Validation schemas
Add to `packages/utils/src/validation/lesson/`:
- `ZLessonAiParam` -- `{ lessonId: z.string().uuid() }`
- `ZLessonAiQuery` -- `{ locale: z.enum([...]).optional().default('en') }`
---
## Phase 2: Polar Credit Purchases
### 2a. Create Polar products
Create credit pack products in Polar dashboard (not code -- manual setup):
- "50 AI Credits" -- one-time purchase
- "200 AI Credits" -- one-time purchase
### 2b. Credit pack constants
Add product IDs to [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts):
```typescript
export const AI_CREDIT_PACKS = {
SMALL: { credits: 50, productId: '<polar-product-id>' },
LARGE: { credits: 200, productId: '<polar-product-id>' },
};
```
### 2c. Polar webhook extension
Extend [apps/dashboard/src/routes/api/polar/webhook/+server.ts](apps/dashboard/src/routes/api/polar/webhook/+server.ts):
- Add a new `order.created` (or `checkout.updated` with `status === 'succeeded'`) handler
- Match product ID against `AI_CREDIT_PACKS` to determine credits
- Call a new API route to add credits: `POST /organization/ai-credits/purchase`
- That route calls `addPurchasedCredits(orgId, credits, polarOrderId)`
### 2d. Checkout flow
Reuse the existing Polar checkout pattern from [apps/dashboard/src/routes/api/polar/subscribe/+server.ts](apps/dashboard/src/routes/api/polar/subscribe/+server.ts) -- create a similar `GET /api/polar/ai-credits` endpoint that creates a one-time checkout (not subscription) with `metadata: { orgId, type: 'ai-credits', credits: N }`.
---
## Phase 3: Frontend -- Teacher Edit Mode
### 3a. Types
Add to [apps/dashboard/src/lib/features/course/utils/types.ts](apps/dashboard/src/lib/features/course/utils/types.ts):
```typescript
export type SummarizeLessonRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].summarize.$post;
export type GenerateLessonAudioRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].audio.$post;
export type GetLessonAiStatusRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-status']['$get'];
export type GetLessonAiCostRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-cost']['$get'];
```
### 3b. LessonApi extensions
Add to [apps/dashboard/src/lib/features/course/api/lesson.svelte.ts](apps/dashboard/src/lib/features/course/api/lesson.svelte.ts):
- State: `aiStatus`, `aiCost`, `isSummarizing`, `isGeneratingAudio`
- Methods: `getAiStatus(courseId, lessonId, locale)`, `getAiCost(courseId, lessonId, locale)`, `summarize(courseId, lessonId, locale)`, `generateAudio(courseId, lessonId, locale)`
### 3c. AI credit API class
New file: `apps/dashboard/src/lib/features/course/api/ai-credits.svelte.ts`
- State: `balance` (plan remaining, purchased remaining, total)
- Methods: `getBalance()`, `purchaseCredits(packId)` (redirects to Polar checkout)
### 3d. Lesson AI tab (edit mode)
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/ai-tab.svelte`
This is a new tab (value `5`) added to the lesson edit tabs in [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts) alongside Note, Slide, Video, Document. Uses a sparkle/wand icon.
Layout:
- **Credit balance bar** at top: "AI Credits: 42 remaining (plan: 35, purchased: 7)" with a "Buy more" link to settings
- **Summary section:**
- Status: "Not generated" / "Generated" / "Stale (content changed)"
- "Generate Summary" button showing cost (e.g., "1 credit")
- If generated: preview of the summary text, "Regenerate" button
- **Audio section:**
- Status: same pattern
- "Generate Audio" button showing cost (e.g., "5 credits")
- If generated: inline audio player, "Regenerate" button
- If stale: warning that content has changed since last generation
### 3e. Integrate AI tab
In [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte):
- Add AI tab to the `UnderlineTabs` in edit mode
- Load `aiStatus` and `aiCost` when entering the AI tab (lazy load)
- Load `aiCredits.balance` when entering the AI tab
In [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts):
- Add `{ label: 'course.navItem.lessons.materials.tabs.ai.title', icon: SparklesIcon, value: 5, badgeValue: 0 }`
---
## Phase 4: Frontend -- Student View Mode
### 4a. Student toolbar
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-ai-toolbar.svelte`
Placed in the lesson view mode block in [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte), between the note content and the Comments section. Shown only when `aiStatus` has generated content.
- **"Listen to lesson"** button (headphones icon, dark bg) -- shown only when `aiStatus.hasAudio` is true. Toggles an inline audio player.
- **"Summarize"** button (zap icon, outline) -- shown only when `aiStatus.hasSummary` is true. Toggles the summary panel below.
### 4b. Audio player
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-audio-player.svelte`
- Native `<audio>` with custom play/pause/progress controls
- Receives `audioUrl` from `lessonApi.aiStatus`
### 4c. Summary panel
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-summary-panel.svelte`
- Styled card matching the screenshot: rounded corners, "AI SUMMARY" heading in gold/amber, bullet-point list
- Renders markdown bullet points from the cached summary
- Collapsible (toggled by the Summarize button)
### 4d. Auto-load AI status
In [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte), when in view mode and lesson is loaded, call `lessonApi.getAiStatus(courseId, lessonId, locale)` to determine which buttons to show.
---
## Phase 5: Settings -- AI Credits Management
### 5a. AI credits settings section
Either add a section to the existing billing page at [billing.svelte](apps/dashboard/src/lib/features/settings/pages/billing.svelte), or create a new settings tab. Shows:
- Current AI credit balance (plan + purchased breakdown)
- Monthly plan allowance and usage bar
- "Buy Credits" section with credit pack cards (50 / 200) linking to Polar checkout
- Usage history (recent AI generations with lesson name, type, credits, date)
---
## Phase 6: Translations and Polish
Add keys to [en.json](apps/dashboard/src/lib/utils/translations/en.json):
- `course.navItem.lessons.materials.tabs.ai.title` -- "AI"
- `course.navItem.lessons.ai.summary_status_*` -- not generated / generated / stale
- `course.navItem.lessons.ai.audio_status_*` -- same
- `course.navItem.lessons.ai.generate_summary` -- "Generate Summary"
- `course.navItem.lessons.ai.generate_audio` -- "Generate Audio"
- `course.navItem.lessons.ai.regenerate` -- "Regenerate"
- `course.navItem.lessons.ai.credits_remaining` -- "{count} credits remaining"
- `course.navItem.lessons.ai.buy_more` -- "Buy more credits"
- `course.navItem.lessons.ai.credit_cost` -- "{count} credit(s)"
- `course.navItem.lessons.ai.content_stale` -- "Content has changed since last generation"
- `course.navItem.lessons.ai.listen_to_lesson` -- "Listen to lesson"
- `course.navItem.lessons.ai.summarize` -- "Summarize"
- `course.navItem.lessons.ai.summary_heading` -- "AI Summary"
- `settings.billing.ai_credits.*` -- balance, purchase, usage history strings
---
## Key Files to Modify
**New files:**
- `packages/utils/src/plans/ai-credits.ts`
- `packages/db/src/queries/organization/ai-credits.ts`
- `packages/db/src/queries/lesson/ai-cache.ts`
- `apps/api/src/services/organization/ai-credits.ts`
- `apps/api/src/services/lesson/lesson-ai.ts`
- `apps/dashboard/src/lib/features/course/api/ai-credits.svelte.ts`
- `apps/dashboard/src/lib/features/course/components/lesson/ai/ai-tab.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-ai-toolbar.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-audio-player.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-summary-panel.svelte`
**Modified files:**
- `packages/db/src/schema.ts` -- 3 new tables, 2 new enums
- `packages/utils/src/constants/error-codes.ts` -- new error codes
- `packages/utils/src/plans/index.ts` -- re-export ai-credits
- `apps/api/src/config/env.ts` -- OPENAI_API_KEY
- `apps/api/src/routes/course/lesson.ts` -- 4 new routes
- `apps/api/src/routes/organization/organization.ts` -- AI credit balance route
- `apps/dashboard/src/routes/api/polar/webhook/+server.ts` -- credit purchase handler
- `apps/dashboard/src/lib/features/course/pages/lesson.svelte` -- AI tab + student toolbar
- `apps/dashboard/src/lib/features/course/components/lesson/constants.ts` -- new tab entry
- `apps/dashboard/src/lib/features/course/components/lesson/index.ts` -- new exports
- `apps/dashboard/src/lib/features/course/api/lesson.svelte.ts` -- AI methods
- `apps/dashboard/src/lib/features/course/utils/types.ts` -- new request types
- `apps/dashboard/src/lib/utils/translations/en.json` -- new keys
@@ -0,0 +1,219 @@
---
name: Public API Tests
overview: Add a Vitest suite for `/public-api/v1` with explicit tests that Bearer API keys, scopes, and org context are enforced, plus strict JSON assertions on success and error responses. DB and external I/O stay mocked; one small Hono app uses real middleware with a mocked auth service to prove the full chain.
todos:
- id: vitest-config
content: Update vitest.config.ts with @api path alias
status: pending
- id: courses-routes
content: Create courses route tests with strict response body assertions
status: pending
- id: audience-routes
content: Create audience route tests with strict response body assertions
status: pending
- id: auth-chain
content: Add public-api-auth-chain tests (real middleware + mocked authenticate + mocked services)
status: pending
- id: automation-key-mw
content: Create automation-key middleware tests (Bearer + touch + JSON error shape)
status: pending
- id: automation-scopes-mw
content: Create automation-key-scopes tests (403 without public_api:*, JSON shape)
status: pending
- id: course-service
content: Create v1 course service unit tests (returns + mock call args)
status: pending
- id: audience-service
content: Create v1 audience service unit tests (returns + mock call args)
status: pending
isProject: false
---
# Public API Test Suite
## Architecture
Three layers of tests, each mocking only the layer below it.
```mermaid
flowchart TD
RouteTests["Route Tests\n(mock middleware + services)"]
MiddlewareTests["Middleware Tests\n(mock DB/auth-key service)"]
ServiceTests["Service Tests\n(mock DB queries)"]
RouteTests --> MiddlewareTests
RouteTests --> ServiceTests
MiddlewareTests --> ServiceTests
```
## Coverage vs codebase (audit)
In this repository, **public API** means the org automation key surface at `/public-api/v1` ([apps/api/src/app.ts](apps/api/src/app.ts)). Nothing else is mounted there; [apps/api/src/routes/v1/index.ts](apps/api/src/routes/v1/index.ts) only wires `/audience` and `/courses`. Other Hono routes (for example `/organization`, `/course`) use session or dashboard flows and are out of scope for this plan.
Full endpoint inventory the plan must exercise (paths relative to `/public-api/v1`):
- `GET /courses`, `POST /courses`
- `GET /courses/:courseId/students`, `GET /courses/:courseId/export`, `GET /courses/:courseId/structure`, `PUT /courses/:courseId/structure`
- `GET /courses/:courseId`, `PUT /courses/:courseId`, `DELETE /courses/:courseId`
- `GET /audience`, `POST /audience`, `POST /audience/assign-courses`
- `GET /audience/:memberId`, `PUT /audience/:memberId`, `DELETE /audience/:memberId`
Validation for these lives under [packages/utils/src/validation/public-api/](packages/utils/src/validation/public-api/) (`course.ts`, `audience.ts` only). If new routers are added under `v1/`, extend this inventory and add matching test files.
## Auth, API keys, and middleware enforcement
Public endpoints must never rely on “trust the client.” Tests must prove the following, aligned with [apps/api/src/middlewares/automation-key.ts](apps/api/src/middlewares/automation-key.ts) and [apps/api/src/middlewares/automation-key-scopes.ts](apps/api/src/middlewares/automation-key-scopes.ts).
**Bearer and API key validation (`automationKeyMiddleware`)**
- Missing `Authorization` → **401**, body `success: false`, stable `code` (e.g. `UNAUTHORIZED`).
- Header present but not `Bearer <token>` → **401** (same shape).
- Empty or whitespace-only token after `Bearer` → **401** (authenticate fails).
- `authenticateOrganizationApiKeyService` throws (invalid / unknown key) → **401**, message surfaced from middleware catch path.
- Successful auth → `c.set('orgId', …)`, `c.set('actorId', …)`, `c.set('automationKey', …)`; assert `touchOrganizationApiKeyLastUsedService` is invoked with the resolved key (proves real keys go through the “touch” path). Expiry is enforced inside `authenticateOrganizationApiKeyService` ([apps/api/src/services/organization/automation-key.ts](apps/api/src/services/organization/automation-key.ts)) — add a mock case where the service throws for an expired key and expect **401**.
**Scope enforcement (`automationKeyScopesMiddleware(['public_api:*'])`)**
- No `automationKey` in context (simulating a bug or bypass) → **401**.
- Key present but `scopes` omit `public_api:*` (e.g. only MCP scopes) → **403**, body includes `code: FORBIDDEN` and a clear error string.
- Key includes `public_api:*` → `next()` runs and the handler can execute.
**Full chain (no mocked middleware on the router)**
Route files that mock middleware cannot prove enforcement by themselves. Add **[apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts](apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts)**:
- Build a small Hono app that mounts a real v1 router (e.g. `v1CoursesRouter` under `/courses`) with **unmocked** `automationKeyMiddleware` and `automationKeyScopesMiddleware`.
- Mock only `@api/services/organization/automation-key` (`authenticateOrganizationApiKeyService`, `touchOrganizationApiKeyLastUsedService`) and the v1 service module.
- Cases: no `Authorization` → 401; `Bearer` + key that authenticates to a record **without** `public_api:*` → 403; `Bearer` + key with `public_api:*` → 200 and JSON matches the mocked service return (see next section).
- Optionally repeat one path on `v1AudienceRouter` so audience is covered by the same guarantees.
**Service-level actor rules**
Several handlers require `actorId` from the key’s `createdByProfileId`. With middleware mocked to `actorId: null`, assert **401** and `AppError` / `handleError` shape for: `POST /courses`, `PUT /courses/:courseId/structure`, `POST /audience`, `PUT /audience/:memberId`. This documents that automation keys without a creator profile cannot perform those mutations.
## Response contracts (data must match expectations)
Do not only assert HTTP status. Parse `await response.json()` and validate **shape and values**.
**Success responses**
- Every happy path: `expect(body.success).toBe(true)`.
- `GET/POST` course list/detail: `data` must **deep-equal** (or `toMatchObject` with explicit fields) the object/array returned by the mocked service — proves the handler forwards the service result, not an accidental empty object or wrong key.
- `GET /audience`: assert `data`, `pagination`, and `query` exist and match the mocked `listAudienceService` return (same values the service produced).
- `POST` create paths: assert **201** where applicable and `data` matches the created entity mock.
- Where services return nested structures (course export / structure), assert at least stable top-level keys and one nested field so refactors do not silently change the contract.
**Error responses**
- Middleware errors: `success: false`, string `error`, string `code` — match actual payloads from middleware (401/403).
- `handleError` / `AppError` paths: `success: false`, `error` message, `code` matching `ErrorCodes.*`, optional `field` when the route sets it. Assert status equals `AppError.statusCode` (404, 401, etc.).
- Validation failures (Zod / hono-openapi): assert **4xx** and that the body documents validation failure in the form the stack returns (status + JSON); do not hand-wave as “400” without reading the response.
**Service unit tests**
- When a function maps or merges arguments (e.g. `createPublicApiCourseService` adds `organizationId`), assert the **exact** arguments passed into the downstream mock (`toHaveBeenCalledWith`).
## Files to Change
### 1. `[apps/api/vitest.config.ts](apps/api/vitest.config.ts)`
Add `resolve.alias` so Vitest resolves `@api/*` without needing `tsc-alias` at runtime:
```ts
import { resolve } from 'path';
export default defineConfig({
resolve: { alias: { '@api': resolve(__dirname, 'src') } },
test: { globals: true, environment: 'node', ... }
});
```
(`@cio/*` packages already resolve from `node_modules` at test time.)
### 2. Route tests (mocked middleware — fast handler + validation coverage)
These files focus on **validators**, **handler wiring**, and **error mapping** with middleware stubbed so `orgId` / `actorId` are deterministic.
`**[apps/api/src/routes/v1/__tests__/courses.test.ts](apps/api/src/routes/v1/__tests__/courses.test.ts)**`
- `vi.mock` both middleware modules to inject `orgId`/`actorId` and call `next()`.
- `vi.mock('@api/services/v1/course')` — all service functions as `vi.fn()`.
- One `describe` block per endpoint. For **each** success case: set the mock return to a **concrete** object/array, perform the request, `const body = await res.json()`, then `expect(body).toEqual({ success: true, data: … })` (or `toMatchObject` only where the payload is huge and you document which fields are guaranteed).
- Cases (plus response assertions as above):
- `GET /` — 200; service throws `AppError` → assert status + `{ success: false, code, error }`
- `POST /` — 201 + body matches mock; invalid body → validation status + body; middleware mock with `actorId` undefined/null → 401 + error contract
- `GET /:courseId` — 200 + `data` equals `getCourseService` mock; non-UUID param → 4xx; `AppError` 404 → 404 + `COURSE_NOT_FOUND` (or whatever the service throws)
- `PUT /:courseId`, `DELETE /:courseId` — same pattern
- `GET …/students`, `export`, `structure` — 200 + `data` equals mock; structure route shares export service — assert same mock used if both tested
- `PUT …/structure` — 200 + merged result shape; no `actorId` → 401; invalid JSON → validation error
- Use `v1CoursesRouter.request('/path', { headers, body })`.
`**[apps/api/src/routes/v1/__tests__/audience.test.ts](apps/api/src/routes/v1/__tests__/audience.test.ts)**`
- Same middleware mocks as courses; mock `@api/services/v1/audience`.
- Assert full list payload: `success`, `data`, `pagination`, `query` **equal** mocked `listAudienceService` output fields.
- Other endpoints: same success/error JSON rules as courses.
### 2b. Auth chain tests (real middleware)
`**[apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts](apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts)**`
- Do **not** mock `automationKeyMiddleware` / `automationKeyScopesMiddleware`.
- Mock `authenticateOrganizationApiKeyService` to return a full key-shaped object (`organizationId`, `createdByProfileId`, `scopes`, `id`, etc. — whatever the middleware reads).
- Mount router under a prefix so paths match production (`/courses` + `GET /` → list).
- Prove **401** without header, **403** when `scopes` is `[]` or MCP-only, **200** when `scopes` includes `public_api:*` and `data` matches mocked `listCoursesService` (or one audience list call).
### 3. Middleware tests
`**[apps/api/src/middlewares/__tests__/automation-key.test.ts](apps/api/src/middlewares/__tests__/automation-key.test.ts)**`
- `vi.mock('@api/services/organization/automation-key')` — mock `authenticateOrganizationApiKeyService` and `touchOrganizationApiKeyLastUsedService`.
- Create a minimal Hono app that mounts the real `automationKeyMiddleware` + a `GET /` handler that returns context values.
- Cases (each: `await res.json()` and assert `success`, `code`, `error` where applicable):
- No `Authorization` header → 401 + `success: false` + `code: UNAUTHORIZED`
- Header not `Bearer …` → 401 + same JSON shape
- `authenticateOrganizationApiKeyService` throws (invalid or expired key; service throws before returning) → 401 + body matches middleware catch behavior
- Valid key → 200; response reflects `orgId`, `actorId`, `automationKey` from context (echo in test handler)
- `touchOrganizationApiKeyLastUsedService` called exactly once with the authenticated key
`**[apps/api/src/middlewares/__tests__/automation-key-scopes.test.ts](apps/api/src/middlewares/__tests__/automation-key-scopes.test.ts)**`
- No DB mocking needed; test `organizationApiKeyHasScopes` logic via the scope middleware itself.
- Cases (parse JSON on 401/403):
- `automationKey` not set in context → 401 + `success: false`
- Key with `scopes: []` or without `public_api:*` → 403 + `code: FORBIDDEN` + error message string
- Key with `public_api:*` → handler runs; response body proves `next()` was reached
### 4. Service unit tests
For every exported function, assert **return value** equals the expected object (or key fields) after the mocked dependency returns — not only “did not throw.” Use `expect(fn).toHaveBeenCalledWith(...)` for argument contracts.
`**[apps/api/src/services/v1/__tests__/course.test.ts](apps/api/src/services/v1/__tests__/course.test.ts)**`
Mock: `@cio/db/queries/tag` (`getCourseOrganizationId`), `@api/services/organization` (`getOrganizationCourses`), `@api/services/course/course` (`getCourse`, `updateCourse`, `deleteCourse`, `createCourse`), `@api/services/course/people` (`listCourseMembers`), course-import services.
- `assertCourseBelongsToOrg` helper: course not found → throws `AppError(404, COURSE_NOT_FOUND)`; org mismatch → same
- `listCoursesService` — returns `items` from `getOrganizationCourses`
- `getCourseService` — happy path; unauthorized org
- `listCourseStudentsService` — filters by `ROLE.STUDENT`
- `exportCourseService` — delegates to `getCourseImportStructureService`
- `createPublicApiCourseService` — no `actorId` → 401; calls `createCourse` with `organizationId` merged in
- `updatePublicApiCourseService` — calls `updateCourseService`
- `deletePublicApiCourseService` — calls `deleteCourseService`
- `updatePublicApiCourseStructureService` — no `actorId` → 401; creates draft then publishes
`**[apps/api/src/services/v1/__tests__/audience.test.ts](apps/api/src/services/v1/__tests__/audience.test.ts)**`
Mock: `@api/services/organization` (`getOrgAudience`, `getOrgAudienceMember`, `removeAudienceMember`), `@api/services/organization/audience` (`assignAudienceToCourses`, `importAudienceMembers`, `updatePendingAudienceMemberEmail`).
- `listAudienceService` — delegates to `getOrgAudience`
- `createAudienceService` — no `actorId` → 401; maps payload fields correctly
- `getAudienceMemberService` — passes through
- `assignAudienceCoursesService` — passes through
- `removeAudienceService` — passes through; `actorId` null defaults to `undefined`
- `updateAudienceService` — no `actorId` → 401; calls update then re-fetches member
## Total: 8 new files, 1 updated file