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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user