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
+4
View File
@@ -92,6 +92,10 @@ OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=http://localhost:9000/media
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
# Dashboard: Google Drive video picker (optional). Web OAuth client ID + browser API key; enable Picker API and Drive API on the same GCP project.
# PUBLIC_GOOGLE_PICKER_CLIENT_ID=
# PUBLIC_GOOGLE_PICKER_API_KEY=
# Cookie domain for auth. Set to your root domain (e.g. .yourdomain.com) when API and dashboard use different subdomains. # Cookie domain for auth. Set to your root domain (e.g. .yourdomain.com) when API and dashboard use different subdomains.
# Without this, auth cookies may not be set and login will fail. # Without this, auth cookies may not be set and login will fail.
AUTH_COOKIE_DOMAIN= AUTH_COOKIE_DOMAIN=
+61
View File
@@ -0,0 +1,61 @@
name: Deploy embeds to R2
on:
push:
branches: [main]
paths:
- "packages/ui/**"
- "packages/question-types/**"
- "apps/embeds/**"
workflow_dispatch:
env:
NODE_VERSION: "20"
PNPM_VERSION: "8"
jobs:
build-and-deploy-embeds:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm i
- name: Build and upload embeds to R2
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: pnpm --filter @cio/embeds run embeds:publish
env:
CLOUDFLARE_ACCESS_KEY: ${{ secrets.CLOUDFLARE_ACCESS_KEY }}
CLOUDFLARE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Optional: purges Cloudflare edge cache for EMBED_CDN_BASE_URL (R2 has no HTTP cache). Token needs Zone → Cache Purge → Purge.
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
EMBED_CDN_BASE_URL: ${{ vars.EMBED_CDN_BASE_URL }}
+1
View File
@@ -24,6 +24,7 @@ Use this file to keep a single prioritized view of all PRDs.
| - | 10 | `prd/pricing-improvements` | todo | | - | 10 | `prd/pricing-improvements` | todo |
| - | 11 | `prd/public-api` | todo | | - | 11 | `prd/public-api` | todo |
| - | 12 | `prd/public-courses` | todo | | - | 12 | `prd/public-courses` | todo |
| - | - | `prd/customer-course-documentation` | todo |
| - | 15 | `prd/webhooks` | todo | | - | 15 | `prd/webhooks` | todo |
## DONE (Completed PRDs) ## DONE (Completed PRDs)
+6 -5
View File
@@ -1,6 +1,6 @@
# OpenAPI Spec Generator Script # OpenAPI Spec Generator Script
This script generates the OpenAPI specification from your Hono application and optionally uploads it to a Cloudflare R2 bucket. This script generates the OpenAPI specification for the public API routes only and optionally uploads it to a Cloudflare R2 bucket.
## Usage ## Usage
@@ -38,10 +38,11 @@ CLOUDFLARE_ACCOUNT_ID=your_account_id
The script will: The script will:
1. **Generate** the OpenAPI specification from your Hono app routes 1. **Generate** the OpenAPI specification from your Hono app routes
2. **Save locally** to `dist/openapi/openapi.json` 2. **Filter** the output to `/public-api/v1/*`
3. **Upload to R2** (if credentials provided): 3. **Save locally** to `dist/openapi/public-api/openapi.json`
- `openapi/openapi-YYYY-MM-DD.json` (dated version) 4. **Upload to R2** (if credentials provided):
- `openapi/openapi-latest.json` (latest version) - `openapi/public-api/openapi-YYYY-MM-DD.json` (dated version)
- `openapi/public-api/openapi-latest.json` (latest version)
## GitHub Action Integration ## GitHub Action Integration
+35 -5
View File
@@ -16,6 +16,35 @@ interface UploadOptions {
contentType?: string; contentType?: string;
} }
function filterPublicApiSpec(spec: Record<string, unknown>) {
const paths = (spec.paths ?? {}) as Record<string, unknown>;
const publicApiPaths = Object.fromEntries(
Object.entries(paths).filter(([path]) => path.startsWith('/public-api/v1/'))
);
const tags = Array.isArray(spec.tags)
? spec.tags.filter((tag) => {
if (!tag || typeof tag !== 'object' || !('name' in tag)) {
return false;
}
return typeof tag.name === 'string' && tag.name.startsWith('Public API');
})
: undefined;
return {
...spec,
openapi: spec.openapi ?? '3.1.0',
info: {
title: 'ClassroomIO Public API',
version: '1.0.0',
description: 'Public API for managing organizations, audience members, and courses in ClassroomIO.'
},
paths: publicApiPaths,
tags
};
}
class OpenAPISpecGenerator { class OpenAPISpecGenerator {
private s3Client: S3Client | null = null; private s3Client: S3Client | null = null;
@@ -68,7 +97,8 @@ class OpenAPISpecGenerator {
} }
}); });
const specString = JSON.stringify(spec, null, 2); const publicApiSpec = filterPublicApiSpec(spec as Record<string, unknown>);
const specString = JSON.stringify(publicApiSpec, null, 2);
console.log('✅ OpenAPI specification generated successfully'); console.log('✅ OpenAPI specification generated successfully');
return specString; return specString;
@@ -80,7 +110,7 @@ class OpenAPISpecGenerator {
async saveLocalSpec(spec: string): Promise<string> { async saveLocalSpec(spec: string): Promise<string> {
try { try {
const outputDir = join(process.cwd(), 'dist', 'openapi'); const outputDir = join(process.cwd(), 'dist', 'openapi', 'public-api');
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
const filePath = join(outputDir, 'openapi.json'); const filePath = join(outputDir, 'openapi.json');
@@ -122,10 +152,10 @@ class OpenAPISpecGenerator {
try { try {
const spec = await this.generateSpec(); const spec = await this.generateSpec();
// await this.saveLocalSpec(spec); await this.saveLocalSpec(spec);
if (this.s3Client) { if (this.s3Client) {
const uploadKey = `openapi/openapi-${new Date().toISOString().split('T')[0]}.json`; const uploadKey = `openapi/public-api/openapi-${new Date().toISOString().split('T')[0]}.json`;
await this.uploadToR2({ await this.uploadToR2({
key: uploadKey, key: uploadKey,
content: spec, content: spec,
@@ -134,7 +164,7 @@ class OpenAPISpecGenerator {
// Also upload as latest version // Also upload as latest version
await this.uploadToR2({ await this.uploadToR2({
key: 'openapi/openapi-latest.json', key: 'openapi/public-api/openapi-latest.json',
content: spec, content: spec,
contentType: 'application/json' contentType: 'application/json'
}); });
+4
View File
@@ -20,11 +20,13 @@ import { organizationRouter } from '@api/routes/organization';
import { licenseRouter } from '@api/routes/license'; import { licenseRouter } from '@api/routes/license';
import { organizationSsoRouter } from '@api/routes/organization/sso'; import { organizationSsoRouter } from '@api/routes/organization/sso';
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth'; import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
import { v1Router } from '@api/routes/v1';
import { ssoDiscoveryRouter } from '@api/routes/sso/discovery'; import { ssoDiscoveryRouter } from '@api/routes/sso/discovery';
import { prettyJSON } from 'hono/pretty-json'; import { prettyJSON } from 'hono/pretty-json';
import rateLimiter from '@api/middlewares/rate-limiter'; import rateLimiter from '@api/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers'; import { secureHeaders } from 'hono/secure-headers';
import { signupGuard } from '@api/middlewares/signup-guard'; import { signupGuard } from '@api/middlewares/signup-guard';
import { programRouter } from '@api/routes/program';
import { unsplashRouter } from '@api/routes/unsplash/unsplash'; import { unsplashRouter } from '@api/routes/unsplash/unsplash';
// Create Hono app with chaining for RPC support // Create Hono app with chaining for RPC support
@@ -94,6 +96,8 @@ export const app = new Hono()
.route('/dash', dashAnalyticsRouter) .route('/dash', dashAnalyticsRouter)
.route('/community', communityRouter) .route('/community', communityRouter)
.route('/invite', inviteRouter) .route('/invite', inviteRouter)
.route('/public-api/v1', v1Router)
.route('/program', programRouter)
.route('/unsplash', unsplashRouter) .route('/unsplash', unsplashRouter)
// Error handling // Error handling
@@ -0,0 +1,35 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
export const automationKeyScopesMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
const automationKey = c.get('automationKey');
if (!automationKey) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: ErrorCodes.UNAUTHORIZED
},
401
);
}
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
return c.json(
{
success: false,
error: 'Automation key is missing required scopes',
code: ErrorCodes.FORBIDDEN
},
403
);
}
return next();
};
@@ -2,6 +2,7 @@ import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors'; import { ErrorCodes } from '@api/utils/errors';
import { isUserCourseMemberOrOrgAdmin } from '@cio/db/queries/group'; import { isUserCourseMemberOrOrgAdmin } from '@cio/db/queries/group';
import { ensureProgramCourseAccess } from '@api/services/course/course';
/** /**
* Middleware to check if the authenticated user is a member of a course's group * Middleware to check if the authenticated user is a member of a course's group
@@ -39,6 +40,11 @@ export const courseMemberMiddleware = async (c: Context, next: Next) => {
return next(); return next();
} }
const backfilledFromProgram = await ensureProgramCourseAccess(courseId, user.id);
if (backfilledFromProgram) {
return next();
}
return c.json( return c.json(
{ {
success: false, success: false,
@@ -0,0 +1,64 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isOrgAdminByProgramId, isProgramMember } from '@cio/db/queries/program';
/**
* Middleware to check if the authenticated user is a member of the program
* or an org admin for the program's organization.
*/
export const programMemberMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
const programId = c.req.param('programId');
if (!programId) {
return c.json(
{
success: false,
error: 'Program ID is required',
code: 'PROGRAM_ID_REQUIRED'
},
400
);
}
const [member, isOrgAdmin] = await Promise.all([
isProgramMember(programId, user.id),
isOrgAdminByProgramId(programId, user.id)
]);
if (member || isOrgAdmin) {
return next();
}
return c.json(
{
success: false,
error: 'You must be a member of this program to perform this action',
code: ErrorCodes.PROGRAM_FORBIDDEN
},
403
);
} catch (error) {
console.error('Error in programMemberMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify program membership',
code: ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED
},
500
);
}
};
@@ -0,0 +1,101 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import {
getProgramMemberByProfileId,
getProgramMemberRole,
getProgramNewsfeedById,
getProgramNewsfeedCommentById,
isOrgAdminByProgramId
} from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants';
export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
const commentId = c.req.param('commentId');
if (!commentId) {
return c.json(
{
success: false,
error: 'Comment ID is required',
code: 'COMMENT_ID_REQUIRED'
},
400
);
}
const comment = await getProgramNewsfeedCommentById(Number(commentId));
if (!comment?.programNewsfeedId) {
return c.json(
{
success: false,
error: 'Comment not found',
code: ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND
},
404
);
}
const feed = await getProgramNewsfeedById(comment.programNewsfeedId);
if (!feed?.programId) {
return c.json(
{
success: false,
error: 'Program newsfeed item not found',
code: ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND
},
404
);
}
const member = await getProgramMemberByProfileId(feed.programId, user.id);
const isAuthor = comment.authorId === member?.id;
if (isAuthor) {
await next();
return;
}
const isOrgAdmin = await isOrgAdminByProgramId(feed.programId, user.id);
if (isOrgAdmin) {
await next();
return;
}
const roleId = await getProgramMemberRole(feed.programId, user.id);
const isTeamMember = roleId === ROLE.ADMIN || roleId === ROLE.TUTOR;
if (isTeamMember) {
await next();
return;
}
return c.json(
{
success: false,
error: 'Only the author or program team members can perform this action',
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
},
403
);
} catch (error) {
console.error('Error in programNewsfeedCommentAuthorOrTeamMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify comment authorization',
code: 'COMMENT_AUTH_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,65 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { getProgramMemberRole, isOrgAdminByProgramId } from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants';
/**
* Middleware to check if the authenticated user is a program teacher or org admin.
* Mirrors the course-team-member pattern for program routes.
*/
export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
const programId = c.req.param('programId');
if (!programId) {
return c.json(
{
success: false,
error: 'Program ID is required',
code: 'PROGRAM_ID_REQUIRED'
},
400
);
}
const [roleId, isOrgAdmin] = await Promise.all([
getProgramMemberRole(programId, user.id),
isOrgAdminByProgramId(programId, user.id)
]);
if (roleId === ROLE.TUTOR || roleId === ROLE.ADMIN || isOrgAdmin) {
return next();
}
return c.json(
{
success: false,
error: 'Unauthorized',
code: ErrorCodes.UNAUTHORIZED
},
403
);
} catch (error) {
console.error('Error in programTeamMemberMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify program team membership',
code: 'PROGRAM_TEAM_MEMBER_CHECK_FAILED'
},
500
);
}
};
+1 -4
View File
@@ -88,8 +88,5 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
// Default rate limiter middleware with standard configuration // Default rate limiter middleware with standard configuration
export default createRateLimiter({ export default createRateLimiter({
maxRequests: (c) => maxRequests: (c) => (c.req.path === '/api/auth/get-session' ? 1000 : DEFAULT_RATE_LIMITER_OPTIONS.maxRequests)
c.req.path === '/api/auth/get-session'
? DEFAULT_RATE_LIMITER_OPTIONS.maxRequests * 3
: DEFAULT_RATE_LIMITER_OPTIONS.maxRequests
}); });
+73 -1
View File
@@ -1,7 +1,12 @@
import { ZCourseInviteTokenParam } from '@cio/utils/validation/course/invite'; import { ZCourseInviteTokenParam } from '@cio/utils/validation/course/invite';
import { previewStudentInvite } from '@api/services/course/invite'; import { previewStudentInvite } from '@api/services/course/invite';
import { ZOrganizationInviteTokenParam } from '@cio/utils/validation/organization/invite'; import { ZOrganizationInviteTokenParam } from '@cio/utils/validation/organization/invite';
import { acceptOrganizationInvite, previewOrganizationInvite } from '@api/services/organization/invite'; import {
acceptOrganizationInvite,
acceptOrganizationInviteById,
getPendingOrgInviteForUser,
previewOrganizationInvite
} from '@api/services/organization/invite';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key'; import { apiKeyMiddleware } from '@api/middlewares/api-key';
@@ -10,6 +15,7 @@ import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { extractClientIp } from '@api/utils/redis/key-generators'; import { extractClientIp } from '@api/utils/redis/key-generators';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import * as z from 'zod';
const previewInviteRateLimit = createRateLimiter({ const previewInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000, windowMs: 60 * 1000,
@@ -36,7 +42,73 @@ const acceptOrganizationInviteRateLimit = createRateLimiter({
} }
}); });
const pendingInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000,
maxRequests: 30,
message: 'Too many pending invite requests. Please try again later.',
keyGenerator: (c) => {
const user = c.get('user');
return `invite_pending:${user?.id ?? extractClientIp(c)}`;
}
});
const acceptByIdRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
maxRequests: 20,
message: 'Too many invite accept attempts. Please try again later.',
keyGenerator: (c) => {
const user = c.get('user');
const actor = user?.id ? `user:${user.id}` : `ip:${extractClientIp(c)}`;
return `org_invite_accept_by_id:${actor}:${c.req.param('inviteId')}`;
}
});
export const inviteRouter = new Hono() export const inviteRouter = new Hono()
/**
* GET /invite/organization/pending
* Returns the active pending org invite for the currently logged-in user.
*/
.get('/organization/pending', authMiddleware, pendingInviteRateLimit, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id');
if (!orgId || !user.email) {
return c.json({ success: true, data: null }, 200);
}
const pendingInvite = await getPendingOrgInviteForUser(orgId, user.email);
return c.json({ success: true, data: pendingInvite }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load pending invite');
}
})
/**
* POST /invite/organization/:inviteId/accept-by-id
* Accepts an org invite by invite ID (for logged-in users with a pending invite surfaced by /pending).
*/
.post(
'/organization/:inviteId/accept-by-id',
authMiddleware,
acceptByIdRateLimit,
zValidator('param', z.object({ inviteId: z.string().uuid() })),
async (c) => {
try {
const { inviteId } = c.req.valid('param');
const user = c.get('user')!;
const result = await acceptOrganizationInviteById(
inviteId,
{ id: user.id, email: user.email },
{ ipAddress: extractClientIp(c), userAgent: c.req.header('user-agent') || null }
);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to accept organization invite');
}
}
)
/** /**
* GET /invite/student/:token * GET /invite/student/:token
* Server-only preview for secure invite links (API key). Returns course, org, invite for enroll page. * Server-only preview for secure invite links (API key). Returns course, org, invite for enroll page.
@@ -386,7 +386,7 @@ export const organizationRouter = new Hono()
const orgId = c.get('orgId'); const orgId = c.get('orgId');
const userRole = c.get('userRole'); const userRole = c.get('userRole');
const automationKey = c.get('automationKey'); const automationKey = c.get('automationKey');
const { tags } = c.req.valid('query'); const query = c.req.valid('query');
if (!orgId) { if (!orgId) {
return c.json( return c.json(
@@ -403,25 +403,28 @@ export const organizationRouter = new Hono()
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses'); await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
} }
const tagSlugs = tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
const result = await getOrganizationCourses( const result = await getOrganizationCourses(
orgId, orgId,
user?.id ?? automationKey?.createdByProfileId ?? '', user?.id ?? automationKey?.createdByProfileId ?? '',
userRole ?? ROLE.ADMIN, userRole ?? ROLE.ADMIN,
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined query
); );
if (automationKey?.type === 'mcp') { if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_org_courses', { tags: tagSlugs }); await recordMcpAutomationUsage(automationKey, 'list_org_courses', {
tags: query.tags,
search: query.search,
page: query.page,
limit: query.limit
});
} }
return c.json( return c.json(
{ {
success: true, success: true,
data: result data: result.items,
pagination: result.pagination,
query: result.query
}, },
200 200
); );
+1
View File
@@ -0,0 +1 @@
export { programRouter } from './program';
+487
View File
@@ -0,0 +1,487 @@
import * as z from 'zod';
import {
ZAddCourseToProgram,
ZAddProgramMembers,
ZCreateProgram,
ZCreateProgramNewsfeed,
ZCreateProgramNewsfeedComment,
ZUpdateProgram,
ZUpdateProgramMember,
ZUpdateProgramNewsfeed,
ZUpdateProgramReaction
} from '@cio/utils/validation/program';
import {
addCourseToProgramService,
addProgramMembers,
createProgram,
createProgramNewsfeedCommentService,
createProgramNewsfeedService,
deleteProgram,
removeProgramMemberService,
deleteProgramNewsfeedCommentService,
deleteProgramNewsfeedService,
getEnrolledPrograms,
getProgram,
listOrgPrograms,
listProgramCourses,
listProgramMembers,
listProgramNewsfeed,
listProgramNewsfeedComments,
removeCourseFromProgramService,
updateProgram,
updateProgramMemberService,
updateProgramNewsfeedReactionService,
updateProgramNewsfeedService
} from '@api/services/program/program';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { programMemberMiddleware } from '@api/middlewares/program-member';
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
const ZProgramParam = z.object({ programId: z.string().uuid() });
const ZMemberParam = z.object({ programId: z.string().uuid(), memberId: z.string().uuid() });
const ZCourseParam = z.object({ programId: z.string().uuid(), courseId: z.string().uuid() });
const ZFeedParam = z.object({ programId: z.string().uuid(), feedId: z.string().uuid() });
const ZCommentParam = z.object({
programId: z.string().uuid(),
feedId: z.string().uuid(),
commentId: z.coerce.number().int()
});
const ZListQuery = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(10)
});
const ZOrgQuery = z.object({ organizationId: z.string().uuid() });
export const programRouter = new Hono()
// ── Enrolled Programs (LMS) ─── must be before /:programId ───────────────
/**
* GET /program/enrolled
* Get programs the current user is enrolled in
*/
.get('/enrolled', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const programs = await getEnrolledPrograms(user.id);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get enrolled programs');
}
})
// ── Programs ──────────────────────────────────────────────────────────────
/**
* GET /program?organizationId=...
* List all programs for an org
*/
.get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const programs = await listOrgPrograms(organizationId);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list programs');
}
})
/**
* POST /program
* Create a new program
*/
.post(
'/',
authMiddleware,
zValidator('json', ZCreateProgram.extend({ organizationId: z.string().uuid() })),
async (c) => {
try {
const user = c.get('user')!;
const { organizationId, ...data } = c.req.valid('json');
const program = await createProgram(organizationId, user.id, data);
return c.json({ success: true, data: program }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program');
}
}
)
/**
* GET /program/:programId
* Get a program by ID
*/
.get('/:programId', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const program = await getProgram(programId);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get program');
}
})
/**
* PUT /program/:programId
* Update a program
*/
.put(
'/:programId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZUpdateProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const program = await updateProgram(programId, data);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program');
}
}
)
/**
* DELETE /program/:programId
* Delete a program
*/
.delete('/:programId', authMiddleware, programTeamMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const program = await deleteProgram(programId);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program');
}
})
// ── Members ───────────────────────────────────────────────────────────────
/**
* GET /program/:programId/members
* List program members
*/
.get(
'/:programId/members',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const { programId } = c.req.valid('param');
const members = await listProgramMembers(programId);
return c.json({ success: true, data: members }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program members');
}
}
)
/**
* POST /program/:programId/members
* Add members to a program
*/
.post(
'/:programId/members',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAddProgramMembers),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addProgramMembers(programId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add program members');
}
}
)
/**
* PUT /program/:programId/members/:memberId
* Update a program member's role
*/
.put(
'/:programId/members/:memberId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZMemberParam),
zValidator('json', ZUpdateProgramMember),
async (c) => {
try {
const { programId, memberId } = c.req.valid('param');
const data = c.req.valid('json');
const member = await updateProgramMemberService(programId, memberId, data);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program member');
}
}
)
/**
* DELETE /program/:programId/members/:memberId
* Remove a member from a program
*/
.delete(
'/:programId/members/:memberId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZMemberParam),
async (c) => {
try {
const { programId, memberId } = c.req.valid('param');
const member = await removeProgramMemberService(programId, memberId);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove program member');
}
}
)
// ── Courses ───────────────────────────────────────────────────────────────
/**
* GET /program/:programId/courses
* List courses in a program
*/
.get(
'/:programId/courses',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const courses = await listProgramCourses(programId, user.id);
return c.json({ success: true, data: courses }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program courses');
}
}
)
/**
* POST /program/:programId/courses
* Add a course to a program
*/
.post(
'/:programId/courses',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAddCourseToProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addCourseToProgramService(programId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add course to program');
}
}
)
/**
* DELETE /program/:programId/courses/:courseId
* Remove a course from a program
*/
.delete(
'/:programId/courses/:courseId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZCourseParam),
async (c) => {
try {
const { programId, courseId } = c.req.valid('param');
const result = await removeCourseFromProgramService(programId, courseId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove course from program');
}
}
)
// ── Newsfeed ──────────────────────────────────────────────────────────────
/**
* GET /program/:programId/newsfeed
* List newsfeed for a program (paginated)
*/
.get(
'/:programId/newsfeed',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('query', ZListQuery),
async (c) => {
try {
const { programId } = c.req.valid('param');
const { cursor, limit } = c.req.valid('query');
const result = await listProgramNewsfeed(programId, { cursor, limit });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program newsfeed');
}
}
)
/**
* POST /program/:programId/newsfeed
* Create a newsfeed post
*/
.post(
'/:programId/newsfeed',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZCreateProgramNewsfeed),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await createProgramNewsfeedService(programId, user.id, data);
return c.json({ success: true, data: feed }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program newsfeed post');
}
}
)
/**
* PUT /program/:programId/newsfeed/:feedId
* Update a newsfeed post
*/
.put(
'/:programId/newsfeed/:feedId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateProgramNewsfeed),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateProgramNewsfeedService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program newsfeed post');
}
}
)
/**
* PUT /program/:programId/newsfeed/:feedId/react
* Update reaction on a newsfeed post
*/
.put(
'/:programId/newsfeed/:feedId/react',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateProgramReaction),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateProgramNewsfeedReactionService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program newsfeed reaction');
}
}
)
/**
* DELETE /program/:programId/newsfeed/:feedId
* Delete a newsfeed post
*/
.delete(
'/:programId/newsfeed/:feedId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const feed = await deleteProgramNewsfeedService(feedId);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program newsfeed post');
}
}
)
/**
* GET /program/:programId/newsfeed/:feedId/comments
* List comments on a newsfeed post
*/
.get(
'/:programId/newsfeed/:feedId/comments',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const comments = await listProgramNewsfeedComments(feedId);
return c.json({ success: true, data: comments }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program newsfeed comments');
}
}
)
/**
* POST /program/:programId/newsfeed/:feedId/comment
* Create a comment on a newsfeed post
*/
.post(
'/:programId/newsfeed/:feedId/comment',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZCreateProgramNewsfeedComment),
async (c) => {
try {
const user = c.get('user')!;
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const comment = await createProgramNewsfeedCommentService(feedId, user.id, data);
return c.json({ success: true, data: comment }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program newsfeed comment');
}
}
)
/**
* DELETE /program/:programId/newsfeed/:feedId/comment/:commentId
* Delete a comment on a newsfeed post
*/
.delete(
'/:programId/newsfeed/:feedId/comment/:commentId',
authMiddleware,
programNewsfeedCommentAuthorOrTeamMiddleware,
zValidator('param', ZCommentParam),
async (c) => {
try {
const { commentId } = c.req.valid('param');
const comment = await deleteProgramNewsfeedCommentService(commentId);
return c.json({ success: true, data: comment }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program newsfeed comment');
}
}
);
+299
View File
@@ -0,0 +1,299 @@
import {
ZPublicApiAssignAudienceCourses,
ZPublicApiAudienceMemberParam,
ZPublicApiAudienceQuery,
ZPublicApiCreateAudience,
ZPublicApiUpdateAudience
} from '@cio/utils/validation/public-api';
import {
assignAudienceCoursesService,
createAudienceService,
getAudienceMemberService,
listAudienceService,
removeAudienceService,
updateAudienceService
} from '@api/services/v1/audience';
import { Hono } from '@api/utils/hono';
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes';
import { handleError } from '@api/utils/errors';
import { describeRoute, validator } from 'hono-openapi';
const AudienceListResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'array' as const, items: { type: 'object' as const } },
pagination: { type: 'object' as const },
query: { type: 'object' as const }
},
required: ['success', 'data', 'pagination', 'query']
};
const AudienceMutationResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'object' as const }
},
required: ['success', 'data']
};
const AudienceDetailResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'object' as const }
},
required: ['success', 'data']
};
export const v1AudienceRouter = new Hono()
.get(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'List audience members for the authenticated organization',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience members returned successfully',
content: {
'application/json': {
schema: AudienceListResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('query', ZPublicApiAudienceQuery),
async (c) => {
try {
const orgId = c.get('orgId')!;
const query = c.req.valid('query');
const audience = await listAudienceService(orgId, query);
return c.json(
{
success: true,
data: audience.items,
pagination: audience.pagination,
query: audience.query
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to list audience members');
}
}
)
.post(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Create an audience member invitation for the authenticated organization',
tags: ['Public API Audience'],
responses: {
201: {
description: 'Audience member created successfully',
content: {
'application/json': {
schema: AudienceMutationResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('json', ZPublicApiCreateAudience),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const payload = c.req.valid('json');
const result = await createAudienceService(orgId, actorId, payload);
return c.json(
{
success: true,
data: result
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create audience member');
}
}
)
.post(
'/assign-courses',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Assign one or more audience members to one or more courses',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience members assigned to courses successfully',
content: {
'application/json': {
schema: AudienceMutationResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('json', ZPublicApiAssignAudienceCourses),
async (c) => {
try {
const orgId = c.get('orgId')!;
const payload = c.req.valid('json');
const result = await assignAudienceCoursesService(orgId, payload);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to assign audience to courses');
}
}
)
.get(
'/:memberId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Get a single audience member by id',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience member returned successfully',
content: {
'application/json': {
schema: AudienceDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Audience member not found' }
}
}),
validator('param', ZPublicApiAudienceMemberParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const audienceMember = await getAudienceMemberService(orgId, params);
return c.json(
{
success: true,
data: audienceMember
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch audience member');
}
}
)
.delete(
'/:memberId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Remove an audience member by id',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience member removed successfully',
content: {
'application/json': {
schema: AudienceMutationResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Audience member not found' }
}
}),
validator('param', ZPublicApiAudienceMemberParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const { memberId } = c.req.valid('param');
const result = await removeAudienceService(orgId, memberId, actorId);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove audience member');
}
}
)
.put(
'/:memberId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Update a pending audience member email address',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience member updated successfully',
content: {
'application/json': {
schema: AudienceDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Audience member not found' }
}
}),
validator('param', ZPublicApiAudienceMemberParam),
validator('json', ZPublicApiUpdateAudience),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const { memberId } = c.req.valid('param');
const payload = c.req.valid('json');
const result = await updateAudienceService(orgId, memberId, actorId, payload);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update audience member');
}
}
);
+410
View File
@@ -0,0 +1,410 @@
import {
ZPublicApiCourseParam,
ZPublicApiCoursesQuery,
ZPublicApiCreateCourse,
ZPublicApiUpdateCourse,
ZPublicApiUpdateCourseStructure
} from '@cio/utils/validation/public-api';
import {
createPublicApiCourseService,
deletePublicApiCourseService,
exportCourseService,
getCourseService,
listCoursesService,
listCourseStudentsService,
updatePublicApiCourseService,
updatePublicApiCourseStructureService
} from '@api/services/v1/course';
import { Hono } from '@api/utils/hono';
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes';
import { handleError } from '@api/utils/errors';
import { describeRoute, validator } from 'hono-openapi';
const CoursesListResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'array' as const, items: { type: 'object' as const } }
},
required: ['success', 'data']
};
const CourseDetailResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'object' as const }
},
required: ['success', 'data']
};
export const v1CoursesRouter = new Hono()
.get(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'List courses for the authenticated organization',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Courses returned successfully',
content: {
'application/json': {
schema: CoursesListResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('query', ZPublicApiCoursesQuery),
async (c) => {
try {
const orgId = c.get('orgId')!;
const query = c.req.valid('query');
const courses = await listCoursesService(orgId, query);
return c.json(
{
success: true,
data: courses
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to list courses');
}
}
)
.post(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Create a course for the authenticated organization',
tags: ['Public API Courses'],
responses: {
201: {
description: 'Course created successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('json', ZPublicApiCreateCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const payload = c.req.valid('json');
const course = await createPublicApiCourseService(orgId, actorId, payload);
return c.json(
{
success: true,
data: course
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create course');
}
}
)
.get(
'/:courseId/students',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'List enrolled students for a course',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course students returned successfully',
content: {
'application/json': {
schema: CoursesListResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const students = await listCourseStudentsService(orgId, params);
return c.json(
{
success: true,
data: students
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to list course students');
}
}
)
.get(
'/:courseId/export',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Export a course structure snapshot',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course export returned successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const courseExport = await exportCourseService(orgId, params);
return c.json(
{
success: true,
data: courseExport
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to export course');
}
}
)
.get(
'/:courseId/structure',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Get a course structure snapshot',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course structure returned successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const courseStructure = await exportCourseService(orgId, params);
return c.json(
{
success: true,
data: courseStructure
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch course structure');
}
}
)
.put(
'/:courseId/structure',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Synchronize a course structure using the draft payload shape',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course structure updated successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
validator('json', ZPublicApiUpdateCourseStructure),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const params = c.req.valid('param');
const payload = c.req.valid('json');
const result = await updatePublicApiCourseStructureService(orgId, actorId, params, payload);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update course structure');
}
}
)
.get(
'/:courseId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Get a single course by id',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course returned successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const course = await getCourseService(orgId, params);
return c.json(
{
success: true,
data: course
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch course');
}
}
)
.put(
'/:courseId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Update a course by id',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course updated successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
validator('json', ZPublicApiUpdateCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const payload = c.req.valid('json');
const course = await updatePublicApiCourseService(orgId, params, payload);
return c.json(
{
success: true,
data: course
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update course');
}
}
)
.delete(
'/:courseId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Delete a course by id',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course deleted successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const course = await deletePublicApiCourseService(orgId, params);
return c.json(
{
success: true,
data: course
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to delete course');
}
}
);
+5
View File
@@ -0,0 +1,5 @@
import { Hono } from '@api/utils/hono';
import { v1AudienceRouter } from './audience';
import { v1CoursesRouter } from './courses';
export const v1Router = new Hono().route('/audience', v1AudienceRouter).route('/courses', v1CoursesRouter);
+1 -1
View File
@@ -45,7 +45,7 @@ export async function fetchCommunityQuestions(
case ROLE.TUTOR: case ROLE.TUTOR:
// Tutors can see questions in courses they have access to // Tutors can see questions in courses they have access to
const tutorCourses = await getOrgCourses({ orgId, profileId: userId }); const tutorCourses = await getOrgCourses({ orgId, profileId: userId });
const tutorCourseIds = tutorCourses.map((course) => course.id); const tutorCourseIds = tutorCourses.items.map((course) => course.id);
discussions = await getCommunityQuestions({ courseIds: tutorCourseIds }); discussions = await getCommunityQuestions({ courseIds: tutorCourseIds });
break; break;
case ROLE.STUDENT: case ROLE.STUDENT:
@@ -16,7 +16,11 @@ import {
updateCourseSectionService updateCourseSectionService
} from '@api/services/course/section'; } from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson'; import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language'; import {
deleteLessonLanguageService,
upsertLessonLanguageService,
updateLessonLanguageService
} from '@api/services/lesson-language';
import { import {
createCourseImportDraft, createCourseImportDraft,
getCourseImportDraftById, getCourseImportDraftById,
@@ -76,6 +80,10 @@ type PublishDraftToExistingCourseResult = PublishDraftResult & {
updatedExercises: number; updatedExercises: number;
createdLessonLanguages: number; createdLessonLanguages: number;
updatedLessonLanguages: number; updatedLessonLanguages: number;
deletedSections: number;
deletedLessons: number;
deletedExercises: number;
deletedLessonLanguages: number;
untouchedSections: number; untouchedSections: number;
untouchedLessons: number; untouchedLessons: number;
untouchedExercises: number; untouchedExercises: number;
@@ -579,7 +587,7 @@ export async function createCourseImportDraftFromCourseService(
export async function getCourseImportStructureService(orgId: string, courseId: string) { export async function getCourseImportStructureService(orgId: string, courseId: string) {
try { try {
return await buildCourseStructureSnapshot(orgId, courseId); return buildCourseStructureSnapshot(orgId, courseId);
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
throw error; throw error;
@@ -863,6 +871,7 @@ export async function publishCourseImportDraftToExistingCourseService(
const existingDraft = await getCourseImportDraftService(orgId, draftId); const existingDraft = await getCourseImportDraftService(orgId, draftId);
const draft = parseStoredDraft(existingDraft); const draft = parseStoredDraft(existingDraft);
const course = await getCourseRecord(orgId, overrides.courseId); const course = await getCourseRecord(orgId, overrides.courseId);
const syncMode = overrides.syncMode ?? 'merge';
const courseTitle = overrides.title ?? draft.course.title; const courseTitle = overrides.title ?? draft.course.title;
const courseDescription = overrides.description ?? draft.course.description; const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type; const courseType = overrides.type ?? draft.course.type;
@@ -1003,10 +1012,10 @@ export async function publishCourseImportDraftToExistingCourseService(
} }
const appliedTagNames = normalizeDraftTagNames(draft.tags); const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) { if (appliedTagNames.length > 0 || syncMode === 'replace') {
await assignCourseTagsByName(orgId, course.id, { await assignCourseTagsByName(orgId, course.id, {
tagNames: appliedTagNames, tagNames: appliedTagNames,
mode: 'merge' mode: syncMode
}); });
} }
@@ -1015,6 +1024,72 @@ export async function publishCourseImportDraftToExistingCourseService(
const referencedExerciseIds = new Set( const referencedExerciseIds = new Set(
(draft.exercises ?? []).map((exercise) => exercise.externalId).filter((id) => existingExerciseIds.has(id)) (draft.exercises ?? []).map((exercise) => exercise.externalId).filter((id) => existingExerciseIds.has(id))
); );
let deletedLessonLanguages = 0;
let deletedExercises = 0;
let deletedLessons = 0;
let deletedSections = 0;
if (syncMode === 'replace') {
const referencedLanguageKeys = new Set(
draft.lessonLanguages
.map((lessonLanguage) => {
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
if (!lessonId || !referencedLessonIds.has(lessonId)) {
return null;
}
return `${lessonId}:${lessonLanguage.locale}`;
})
.filter((languageKey): languageKey is string => Boolean(languageKey))
);
for (const lessonLanguage of existingLessonLanguages) {
const lessonId = lessonLanguage.lessonId;
if (!lessonId) {
continue;
}
if (!referencedLessonIds.has(lessonId)) {
continue;
}
const languageKey = `${lessonId}:${lessonLanguage.locale}`;
if (referencedLanguageKeys.has(languageKey)) {
continue;
}
await deleteLessonLanguageService(lessonId, lessonLanguage.locale as TLocale);
deletedLessonLanguages += 1;
}
for (const existingExercise of existingExercises) {
if (referencedExerciseIds.has(existingExercise.id)) {
continue;
}
await deleteExerciseService(existingExercise.id);
deletedExercises += 1;
}
for (const existingLesson of existingLessons) {
if (referencedLessonIds.has(existingLesson.id)) {
continue;
}
await deleteLessonService(existingLesson.id);
deletedLessons += 1;
}
for (const existingSection of existingSections) {
if (referencedSectionIds.has(existingSection.id)) {
continue;
}
await deleteCourseSectionService(existingSection.id);
deletedSections += 1;
}
}
const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle); const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle);
await updateCourseImportDraft(orgId, draftId, { await updateCourseImportDraft(orgId, draftId, {
@@ -1026,8 +1101,14 @@ export async function publishCourseImportDraftToExistingCourseService(
publishedCourseId: course.id, publishedCourseId: course.id,
updatedSections, updatedSections,
updatedLessons, updatedLessons,
updatedExercises,
createdLessonLanguages, createdLessonLanguages,
updatedLessonLanguages updatedLessonLanguages,
deletedSections,
deletedLessons,
deletedExercises,
deletedLessonLanguages,
syncMode
} }
}); });
@@ -1044,9 +1125,13 @@ export async function publishCourseImportDraftToExistingCourseService(
updatedExercises, updatedExercises,
createdLessonLanguages, createdLessonLanguages,
updatedLessonLanguages, updatedLessonLanguages,
untouchedSections: existingSections.length - referencedSectionIds.size, deletedSections,
untouchedLessons: existingLessons.length - referencedLessonIds.size, deletedLessons,
untouchedExercises: existingExercises.length - referencedExerciseIds.size, deletedExercises,
deletedLessonLanguages,
untouchedSections: Math.max(existingSections.length - referencedSectionIds.size - deletedSections, 0),
untouchedLessons: Math.max(existingLessons.length - referencedLessonIds.size - deletedLessons, 0),
untouchedExercises: Math.max(existingExercises.length - referencedExerciseIds.size - deletedExercises, 0),
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
}; };
} catch (error) { } catch (error) {
+76 -28
View File
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html'; import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import { addGroupMember, createGroup } from '@cio/db/queries/group'; import { addGroupMember, createGroup, getCourseProgramAccess } from '@cio/db/queries/group';
import { import {
createCourseNewsfeed, createCourseNewsfeed,
createCourse as createCourseQuery, createCourse as createCourseQuery,
@@ -21,6 +21,7 @@ import {
import { ContentType, ROLE } from '@cio/utils/constants'; import { ContentType, ROLE } from '@cio/utils/constants';
import type { TCourse } from '@cio/db/types'; import type { TCourse } from '@cio/db/types';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import * as schema from '@db/schema';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise'; import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise'; import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
@@ -29,6 +30,45 @@ import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type Co
const DEFAULT_CONTENT_GROUPING = true; const DEFAULT_CONTENT_GROUPING = true;
const DEFAULT_SECTION_TITLE = 'First Section [edit me]'; const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
export async function ensureProgramCourseAccess(courseId: string, profileId: string) {
const access = await getCourseProgramAccess(courseId, profileId);
if (!access) {
return false;
}
const organizationId = access.organizationId;
if (!organizationId) {
return false;
}
const normalizedEmail = access.profileEmail?.trim() || undefined;
await db.transaction(async (tx) => {
await tx
.insert(schema.organizationmember)
.values({
organizationId,
roleId: access.roleId,
profileId,
email: normalizedEmail,
verified: true
})
.onConflictDoNothing();
await tx
.insert(schema.groupmember)
.values({
groupId: access.courseGroupId,
roleId: access.roleId,
profileId,
email: normalizedEmail
})
.onConflictDoNothing();
});
return true;
}
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) { function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
if (!metadata) return metadata; if (!metadata) return metadata;
@@ -133,49 +173,57 @@ export async function createCourse(
try { try {
const description = sanitizeHtml(data.description); const description = sanitizeHtml(data.description);
const result = await db.transaction(async () => { const result = await db.transaction(async (tx) => {
// 1. Create group const [newGroup] = await createGroup(
const [newGroup] = await createGroup({ {
name: data.title, name: data.title,
description, description,
organizationId: data.organizationId organizationId: data.organizationId
}); },
tx
);
if (!newGroup) { if (!newGroup) {
throw new AppError('Failed to create group', ErrorCodes.INTERNAL_ERROR, 500); throw new AppError('Failed to create group', ErrorCodes.INTERNAL_ERROR, 500);
} }
// 2. Create course with group_id const [newCourse] = await createCourseQuery(
const [newCourse] = await createCourseQuery({ {
title: data.title, title: data.title,
description, description,
type: data.type, type: data.type,
groupId: newGroup.id groupId: newGroup.id
}); },
tx
);
if (!newCourse) { if (!newCourse) {
throw new AppError('Failed to create course', ErrorCodes.INTERNAL_ERROR, 500); throw new AppError('Failed to create course', ErrorCodes.INTERNAL_ERROR, 500);
} }
// 3. Add group member (creator as TUTOR) const [newMember] = await addGroupMember(
const [newMember] = await addGroupMember({ {
profileId, profileId,
groupId: newGroup.id, groupId: newGroup.id,
roleId: ROLE.TUTOR roleId: ROLE.TUTOR
}); },
tx
);
if (!newMember) { if (!newMember) {
throw new AppError('Failed to add group member', ErrorCodes.INTERNAL_ERROR, 500); throw new AppError('Failed to add group member', ErrorCodes.INTERNAL_ERROR, 500);
} }
// 4. Add default news feed await createCourseNewsfeed(
await createCourseNewsfeed({ {
content: sanitizeHtml(`<h2>Welcome to this course 🎉&nbsp;</h2> content: sanitizeHtml(`<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`), <p>Thank you for joining this course and I hope you get the best out of it.</p>`),
courseId: newCourse.id, courseId: newCourse.id,
isPinned: true, isPinned: true,
authorId: newMember.id authorId: newMember.id
}); },
tx
);
return { return {
course: newCourse, course: newCourse,
+21 -14
View File
@@ -1,16 +1,21 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash'; import {
getCourseStats,
getDashOrgStats,
getRecentCertifications,
getTotalCertificatesIssued
} from '@cio/db/queries/dash';
import { OrganisationAnalytics } from '@api/types'; import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@cio/db/queries'; import { getOrgIdBySiteName } from '@cio/db/queries';
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> { export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
const analytics: OrganisationAnalytics = { const analytics: OrganisationAnalytics = {
revenue: 0, totalCertificates: 0,
numberOfCourses: 0, numberOfCourses: 0,
totalStudents: 0, totalStudents: 0,
topCourses: [], topCourses: [],
enrollments: [] recentCertifications: []
}; };
let resolvedOrgId = orgId; let resolvedOrgId = orgId;
@@ -26,13 +31,14 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
} }
try { try {
const [stats, topCourses, enrollments] = await Promise.all([ const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([
getDashOrgStats(resolvedOrgId!), getDashOrgStats(resolvedOrgId!),
getCourseStats(resolvedOrgId!), getCourseStats(resolvedOrgId!),
getRecentEnrollments(resolvedOrgId!) getRecentCertifications(resolvedOrgId!),
getTotalCertificatesIssued(resolvedOrgId!)
]); ]);
analytics.revenue = 0; analytics.totalCertificates = certificateCountRows[0]?.count ?? 0;
analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0; analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0;
analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0; analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
@@ -40,16 +46,17 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
id: c.courseId, id: c.courseId,
title: c.courseTitle, title: c.courseTitle,
enrollments: c.totalStudents, enrollments: c.totalStudents,
completion: c.completionPercentage completion: c.completionPercentage,
certification: c.certificationPercentage
})); }));
analytics.enrollments = enrollments.map((e) => ({ analytics.recentCertifications = recentCertificationRows.map((row) => ({
id: e.profileId, id: row.profileId,
avatarUrl: e.avatarUrl, avatarUrl: row.avatarUrl,
name: e.fullname, name: row.fullname,
courseId: e.courseId, courseId: row.courseId,
course: e.courseTitle, course: row.courseTitle,
date: e.enrolledAt ?? '' date: row.earnedAt ?? ''
})); }));
return analytics; return analytics;
+32 -3
View File
@@ -20,6 +20,7 @@ import {
updateOptions, updateOptions,
updateQuestions updateQuestions
} from '@cio/db/queries/exercise'; } from '@cio/db/queries/exercise';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import type { DbOrTxClient } from '@cio/db/drizzle'; import type { DbOrTxClient } from '@cio/db/drizzle';
@@ -123,7 +124,13 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
} }
// Fetch the complete exercise with questions and options // Fetch the complete exercise with questions and options
return await getExercise(exercise.id); const result = await getExercise(exercise.id);
if (sanitizedData.courseId) {
await touchCourseUpdatedAt(sanitizedData.courseId);
}
return result;
} catch (error) { } catch (error) {
console.error('createExercise error:', error); console.error('createExercise error:', error);
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -193,7 +200,9 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
try { try {
const sanitizedData = sanitizeExercisePayload(data); const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => { const existing = await getExerciseById(exerciseId);
const result = await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient; const txClient = tx as DbOrTxClient;
// Update exercise basic fields only if they changed // Update exercise basic fields only if they changed
@@ -252,6 +261,13 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
// Return the updated exercise // Return the updated exercise
return await getExercise(exerciseId, txClient); return await getExercise(exerciseId, txClient);
}); });
const courseId = existing?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return result;
} catch (error) { } catch (error) {
console.error('updateExerciseService error:', error); console.error('updateExerciseService error:', error);
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -270,7 +286,9 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
try { try {
const sanitizedData = sanitizeExercisePayload(data); const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => { const existing = await getExerciseById(exerciseId);
const result = await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient; const txClient = tx as DbOrTxClient;
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData); const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
@@ -303,6 +321,13 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
return await getExercise(exerciseId, txClient); return await getExercise(exerciseId, txClient);
}); });
const courseId = existing?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return result;
} catch (error) { } catch (error) {
console.error('replaceExerciseService error:', error); console.error('replaceExerciseService error:', error);
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -330,6 +355,10 @@ export async function deleteExerciseService(exerciseId: string): Promise<TExerci
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500); throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
} }
if (exercise.courseId) {
await touchCourseUpdatedAt(exercise.courseId);
}
return deleted; return deleted;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
+46
View File
@@ -2,6 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html'; import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types'; import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
import { import {
deleteLessonLanguage,
getLessonLanguageByLessonIdAndLocale, getLessonLanguageByLessonIdAndLocale,
getLessonLanguagesByLessonId, getLessonLanguagesByLessonId,
updateLessonLanguage, updateLessonLanguage,
@@ -9,6 +10,7 @@ import {
} from '@cio/db/queries/lesson/language'; } from '@cio/db/queries/lesson/language';
import { getLessonById } from '@cio/db/queries/lesson/lesson'; import { getLessonById } from '@cio/db/queries/lesson/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
/** /**
* Gets all language translations for a lesson * Gets all language translations for a lesson
@@ -70,6 +72,10 @@ export async function upsertLessonLanguageService(
lessonId lessonId
}); });
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return language; return language;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -111,6 +117,11 @@ export async function updateLessonLanguageService(
...data, ...data,
content: sanitizeOptionalHtml(data.content) content: sanitizeOptionalHtml(data.content)
}); });
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return language; return language;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -123,3 +134,38 @@ export async function updateLessonLanguageService(
); );
} }
} }
export async function deleteLessonLanguageService(lessonId: string, locale: TLocale): Promise<TLessonLanguage> {
try {
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
const existing = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
if (!existing) {
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
const deleted = await deleteLessonLanguage(lessonId, locale);
if (!deleted) {
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete lesson language',
ErrorCodes.LESSON_LANGUAGE_DELETE_FAILED,
500
);
}
}
+19 -1
View File
@@ -18,6 +18,7 @@ import {
upsertLessonCompletion, upsertLessonCompletion,
type LessonById type LessonById
} from '@cio/db/queries/lesson'; } from '@cio/db/queries/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media'; import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
/** /**
@@ -38,6 +39,8 @@ export async function createLesson(courseId: string, data: TLessonCreate): Promi
throw new AppError('Failed to create lesson', ErrorCodes.INTERNAL_ERROR, 500); throw new AppError('Failed to create lesson', ErrorCodes.INTERNAL_ERROR, 500);
} }
await touchCourseUpdatedAt(courseId);
return lesson; return lesson;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -91,6 +94,10 @@ export async function updateLessonService(lessonId: string, data: TLessonUpdate)
throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500); throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500);
} }
if (updated.courseId) {
await touchCourseUpdatedAt(updated.courseId);
}
return updated; return updated;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -117,6 +124,10 @@ export async function deleteLessonService(lessonId: string): Promise<TLesson> {
throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500); throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500);
} }
if (deleted.courseId) {
await touchCourseUpdatedAt(deleted.courseId);
}
return deleted; return deleted;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -168,7 +179,14 @@ export async function reorderLessons(lessons: TLessonReorder['lessons']): Promis
); );
const updated = await Promise.all(updatePromises); const updated = await Promise.all(updatePromises);
return updated.filter((l): l is TLesson => l !== null); const validLessons = updated.filter((l): l is TLesson => l !== null);
const courseId = validLessons[0]?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return validLessons;
} catch (error) { } catch (error) {
throw new AppError( throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder lessons', error instanceof Error ? error.message : 'Failed to reorder lessons',
+159 -26
View File
@@ -1,14 +1,18 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
import type { TGetAudienceQuery, TGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types'; import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
import { import {
deleteOrganizationAudienceMember,
cancelOrganizationPlan, cancelOrganizationPlan,
createOrganizationPlan, createOrganizationPlan,
deleteOrganizationAudienceMember,
deleteOrganizationMember, deleteOrganizationMember,
getOrgIdBySiteName, getActiveOrganizationPlan,
getFirstOrganizationWithPlans, getFirstOrganizationWithPlans,
getLatestOrgInvitesByEmails, getLatestOrgInvitesByEmails,
getOrgIdBySiteName,
getOrganizationAudience, getOrganizationAudience,
getOrganizationAudienceMember,
getOrganizationBySiteName, getOrganizationBySiteName,
getOrganizationTeam, getOrganizationTeam,
getOrganizations, getOrganizations,
@@ -16,8 +20,8 @@ import {
updateOrganization, updateOrganization,
updateOrganizationPlan updateOrganizationPlan
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
import { import {
countPublishedCoursesBySiteName,
getCoursesById, getCoursesById,
getCoursesBySiteNameForSetup, getCoursesBySiteNameForSetup,
getEnrolledCourses, getEnrolledCourses,
@@ -27,16 +31,18 @@ import {
getOrgCourses, getOrgCourses,
getPublishedCoursesBySiteName getPublishedCoursesBySiteName
} from '@cio/db/queries/course'; } from '@cio/db/queries/course';
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics'; import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types'; import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
import { PLAN } from '@cio/utils/plans';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import { createOrganizationWithOwner } from '@api/services/onboarding'; import { createOrganizationWithOwner } from '@api/services/onboarding';
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite'; import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org'; const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
import type { TGetAudienceQuery } from '@cio/utils/validation/organization';
/** /**
* Creates a new organization with the current user as owner * Creates a new organization with the current user as owner
@@ -169,6 +175,41 @@ export async function getOrgAudience(
} }
} }
export async function getOrgAudienceMember(orgId: string, memberId: number): Promise<OrgAudienceMember> {
try {
const member = await getOrganizationAudienceMember(orgId, memberId);
if (!member) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
if (!member.profileId && member.email) {
const invites = await getLatestOrgInvitesByEmails(orgId, [member.email.toLowerCase()]);
const invite = invites[0];
return {
...member,
status: deriveAudienceMemberStatus(member.profileId, invite)
};
}
return {
...member,
status: deriveAudienceMemberStatus(member.profileId, undefined)
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch audience member',
ErrorCodes.ORG_AUDIENCE_FETCH_FAILED,
500
);
}
}
/** /**
* Removes a student audience member from an organization * Removes a student audience member from an organization
* @param orgId - The organization ID * @param orgId - The organization ID
@@ -218,20 +259,31 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
if (tagSlugs && tagSlugs.length > 0) { if (tagSlugs && tagSlugs.length > 0) {
filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs); filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs);
if (filteredCourseIds.length === 0) { if (filteredCourseIds.length === 0) {
return []; return {
courses: [],
hasMoreCourses: false
};
} }
} }
const courses = await getPublishedCoursesBySiteName(siteName, filteredCourseIds); const totalCourses = await countPublishedCoursesBySiteName(siteName, filteredCourseIds);
const courses = await getPublishedCoursesBySiteName(
siteName,
filteredCourseIds,
PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT
);
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
org.id, org.id,
courses.map((course) => course.id) courses.map((course) => course.id)
); );
return courses.map((course) => ({ return {
...course, courses: courses.map((course) => ({
tags: tagsByCourseId[course.id] ?? [] ...course,
})); tags: tagsByCourseId[course.id] ?? []
})),
hasMoreCourses: totalCourses > courses.length
};
} catch (error) { } catch (error) {
if (error instanceof AppError) throw error; if (error instanceof AppError) throw error;
throw new AppError( throw new AppError(
@@ -251,44 +303,110 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
* - Admins: all courses with totalStudents * - Admins: all courses with totalStudents
* - Tutors: assigned courses with totalStudents * - Tutors: assigned courses with totalStudents
*/ */
export async function getOrganizationCourses(orgId: string, userId: string, userRole: number, tagSlugs?: string[]) { export async function getOrganizationCourses(
orgId: string,
userId: string,
userRole: number,
query: TGetOrganizationCoursesQuery
) {
try { try {
if (!userRole) { if (!userRole) {
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403); throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
} }
const { page, limit, search } = query;
const tagSlugs = query.tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
let filteredCourseIds: string[] | undefined = undefined; let filteredCourseIds: string[] | undefined = undefined;
if (tagSlugs && tagSlugs.length > 0) { if (tagSlugs && tagSlugs.length > 0) {
filteredCourseIds = await getCourseIdsByTagSlugs(orgId, tagSlugs); filteredCourseIds = await getCourseIdsByTagSlugs(orgId, tagSlugs);
if (filteredCourseIds.length === 0) { if (filteredCourseIds.length === 0) {
return []; return {
items: [],
pagination: {
page,
limit,
total: 0,
totalPages: 0
},
query: {
page,
limit,
search,
tags: query.tags
}
};
} }
} }
switch (userRole) { switch (userRole) {
case ROLE.ADMIN: { case ROLE.ADMIN: {
const courses = await getOrgCourses({ orgId, courseIds: filteredCourseIds }); const courses = await getOrgCourses({
orgId,
courseIds: filteredCourseIds,
page,
limit,
search
});
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
orgId, orgId,
courses.map((course) => course.id) courses.items.map((course) => course.id)
); );
return courses.map((course) => ({ return {
...course, items: courses.items.map((course) => ({
tags: tagsByCourseId[course.id] ?? [] ...course,
})); tags: tagsByCourseId[course.id] ?? []
})),
pagination: {
page: courses.page,
limit: courses.limit,
total: courses.total,
totalPages: courses.totalPages
},
query: {
page,
limit,
search,
tags: query.tags
}
};
} }
case ROLE.TUTOR: { case ROLE.TUTOR: {
const courses = await getOrgCourses({ orgId, profileId: userId, courseIds: filteredCourseIds }); const courses = await getOrgCourses({
orgId,
profileId: userId,
courseIds: filteredCourseIds,
page,
limit,
search
});
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
orgId, orgId,
courses.map((course) => course.id) courses.items.map((course) => course.id)
); );
return courses.map((course) => ({ return {
...course, items: courses.items.map((course) => ({
tags: tagsByCourseId[course.id] ?? [] ...course,
})); tags: tagsByCourseId[course.id] ?? []
})),
pagination: {
page: courses.page,
limit: courses.limit,
total: courses.total,
totalPages: courses.totalPages
},
query: {
page,
limit,
search,
tags: query.tags
}
};
} }
default: default:
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403); throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
@@ -433,6 +551,21 @@ export async function createOrgPlan(data: TNewOrganizationPlan) {
*/ */
export async function updateOrg(orgId: string, data: Partial<TOrganization>) { export async function updateOrg(orgId: string, data: Partial<TOrganization>) {
try { try {
// Enforce plan restriction: only paid plans can use non-minimal landing page themes
if (data.landingpage && typeof data.landingpage === 'object') {
const landingpage = data.landingpage as Record<string, unknown>;
const theme = landingpage.theme;
if (typeof theme === 'string' && theme !== 'minimal') {
const activePlan = await getActiveOrganizationPlan(orgId);
const planName = activePlan?.planName ?? PLAN.BASIC;
if (planName === PLAN.BASIC) {
throw new AppError('Landing page themes require a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
}
}
const organization = await updateOrganization(orgId, data); const organization = await updateOrganization(orgId, data);
if (!organization) { if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404); throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
+272 -28
View File
@@ -6,11 +6,13 @@ import type {
} from '@cio/utils/validation/organization'; } from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group'; import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email'; import { buildEmailFromName, sendEmail } from '@cio/email';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import { import {
createOrganizationInvite, createOrganizationInvite,
createOrganizationInviteAudits, createOrganizationInviteAudits,
createOrganizationInvites, createOrganizationInvites,
createOrganizationMembers, createOrganizationMembers,
getOrganizationAudienceMember,
getLatestOrganizationInviteRowByOrgAndEmail, getLatestOrganizationInviteRowByOrgAndEmail,
getOrgMembersByProfileIds, getOrgMembersByProfileIds,
getOrganizationById, getOrganizationById,
@@ -20,6 +22,7 @@ import {
revokeActiveOrganizationInvitesByEmails revokeActiveOrganizationInvitesByEmails
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course'; import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
@@ -113,16 +116,33 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud
let courseNames: string[] = []; let courseNames: string[] = [];
if (data.allCourses) { if (data.allCourses) {
const courses = await getOrgCourses({ orgId }); const courses = await getOrgCourses({ orgId });
courseIds = courses.map((c) => c.id); courseIds = courses.items.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean); courseNames = courses.items.map((c) => c.title).filter(Boolean);
} else if (data.courseIds && data.courseIds.length > 0) { } else if (data.courseIds && data.courseIds.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds }); const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
courseIds = courses.map((c) => c.id); courseIds = courses.items.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean); courseNames = courses.items.map((c) => c.title).filter(Boolean);
} }
return { courseIds, courseNames }; return { courseIds, courseNames };
} }
async function resolveProgramIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let programIds: string[] = [];
let programNames: string[] = [];
if (data.allPrograms) {
const programs = await getProgramsByOrg(orgId);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
} else if (data.programIds && data.programIds.length > 0) {
const programs = await getProgramsByOrg(orgId, data.programIds);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
}
return { programIds, programNames };
}
async function enrollAudienceStudentProfilesInCourses( async function enrollAudienceStudentProfilesInCourses(
orgId: string, orgId: string,
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>, organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
@@ -202,16 +222,98 @@ async function enrollAudienceStudentProfilesInCourses(
}; };
} }
async function enrollAudienceStudentProfilesInPrograms(
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
orgId: string,
profileIds: string[],
programIds: string[],
shouldSendEmail: boolean
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
if (programIds.length === 0 || profileIds.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const uniqueProfileIds = [...new Set(profileIds)];
const orgMembers = await getOrgMembersByProfileIds(orgId, uniqueProfileIds);
const studentMembers = orgMembers.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
const validProfileIds = new Set(studentMembers.map((member) => member.profileId!));
const profileEmailMap = new Map(
studentMembers.filter((member) => member.profileId).map((member) => [member.profileId!, member.email ?? ''])
);
const programs = await getProgramsByOrg(orgId, programIds);
if (programs.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
const validProgramIds = programs.map((program) => program.id);
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
const existingSet = await getExistingProgramMembers(pairs);
const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.programId}:${pair.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await Promise.all(
toInsert.map((pair) =>
addProgramMember({
programId: pair.programId,
roleId: ROLE.STUDENT,
profileId: pair.profileId,
email: profileEmailMap.get(pair.profileId) || undefined
})
)
);
}
let emailsSent = 0;
if (shouldSendEmail && toInsert.length > 0) {
await Promise.all(
toInsert
.filter((pair) => profileEmailMap.get(pair.profileId))
.map(async (pair) => {
const email = profileEmailMap.get(pair.profileId)!;
try {
await sendEmail('studentProgramWelcome', {
to: email,
fields: {
orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError);
}
})
);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
}
async function createStudentOrgInvitesAndSendEmails(input: { async function createStudentOrgInvitesAndSendEmails(input: {
orgId: string; orgId: string;
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>; organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
emails: string[]; emails: string[];
courseIds: string[]; courseIds: string[];
courseNamesLabel: string | undefined; programIds: string[];
accessNamesLabel: string | undefined;
invitedByProfileId: string; invitedByProfileId: string;
shouldSendEmail: boolean; shouldSendEmail: boolean;
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> { }): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
const { orgId, organization, emails, courseIds, courseNamesLabel, invitedByProfileId, shouldSendEmail } = input; const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
input;
if (emails.length === 0) { if (emails.length === 0) {
return { created: 0, emailsSent: 0, emailsFailed: 0 }; return { created: 0, emailsSent: 0, emailsFailed: 0 };
@@ -236,7 +338,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
isRevoked: false, isRevoked: false,
metadata: { metadata: {
source: 'AUDIENCE_IMPORT', source: 'AUDIENCE_IMPORT',
courseIds: courseIds.length > 0 ? courseIds : undefined courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
} }
} }
}; };
@@ -261,7 +364,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
roleId: ROLE.STUDENT, roleId: ROLE.STUDENT,
roleName: 'Student', roleName: 'Student',
expiresAt, expiresAt,
courseIds courseIds,
programIds
} }
}; };
}) })
@@ -283,7 +387,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
orgName: organization.name, orgName: organization.name,
inviteLink, inviteLink,
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel courseNames: accessNamesLabel
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
}); });
@@ -388,15 +492,24 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
} }
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data); const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined; const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data);
const accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const assignResult = await enrollAudienceStudentProfilesInCourses( const assignedToCourses = await enrollAudienceStudentProfilesInCourses(
orgId, orgId,
organization, organization,
existingStudentProfileIds, existingStudentProfileIds,
courseIds, courseIds,
data.sendEmail data.sendEmail
); );
const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
existingStudentProfileIds,
programIds,
data.sendEmail
);
let imported = 0; let imported = 0;
let importEmailsSent = 0; let importEmailsSent = 0;
@@ -425,12 +538,26 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
} }
} }
if (programIds.length > 0) {
const profiles = await getProfilesByEmails(newEmails);
if (profiles.length > 0) {
await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
profiles.map((profile) => profile.id),
programIds,
false
);
}
}
const inviteOutcome = await createStudentOrgInvitesAndSendEmails({ const inviteOutcome = await createStudentOrgInvitesAndSendEmails({
orgId, orgId,
organization, organization,
emails: newEmails, emails: newEmails,
courseIds, courseIds,
courseNamesLabel, programIds,
accessNamesLabel,
invitedByProfileId, invitedByProfileId,
shouldSendEmail: data.sendEmail shouldSendEmail: data.sendEmail
}); });
@@ -447,7 +574,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
organization, organization,
emails: pendingStudentEmails, emails: pendingStudentEmails,
courseIds, courseIds,
courseNamesLabel, programIds,
accessNamesLabel,
invitedByProfileId, invitedByProfileId,
shouldSendEmail: data.sendEmail shouldSendEmail: data.sendEmail
}); });
@@ -457,11 +585,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
return { return {
imported, imported,
assigned: assignResult.assigned, assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolledInCourses: assignResult.alreadyEnrolled, alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
pendingInvitesRenewed: pendingStudentEmails.length, pendingInvitesRenewed: pendingStudentEmails.length,
duplicates: recipients.duplicates.length, duplicates: recipients.duplicates.length,
emailsSent: assignResult.emailsSent + importEmailsSent + pendingEmailsSent, emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
emailsFailed: importEmailsFailed + pendingEmailsFailed emailsFailed: importEmailsFailed + pendingEmailsFailed
}; };
} }
@@ -483,22 +612,32 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
const emailToUse = member.email.toLowerCase().trim(); const emailToUse = member.email.toLowerCase().trim();
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse); const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined; const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? []; const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? [];
const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? [];
let courseIds: string[] = []; let courseIds: string[] = [];
let courseNames: string[] = []; let courseNames: string[] = [];
if (courseIdsFromMetadata.length > 0) { if (courseIdsFromMetadata.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata }); const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
courseIds = courses.map((c) => c.id); courseIds = courses.items.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean); courseNames = courses.items.map((c) => c.title).filter(Boolean);
}
let programIds: string[] = [];
let programNames: string[] = [];
if (programIdsFromMetadata.length > 0) {
const programs = await getProgramsByOrg(orgId, programIdsFromMetadata);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
} }
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId); await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString(); const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const token = generateToken(); const token = generateToken();
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined; const accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const invite = await createOrganizationInvite({ const invite = await createOrganizationInvite({
organizationId: orgId, organizationId: orgId,
@@ -510,7 +649,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
isRevoked: false, isRevoked: false,
metadata: { metadata: {
source: 'AUDIENCE_RESEND', source: 'AUDIENCE_RESEND',
courseIds: courseIds.length > 0 ? courseIds : undefined courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
} }
}); });
@@ -527,7 +667,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
roleId: ROLE.STUDENT, roleId: ROLE.STUDENT,
roleName: 'Student', roleName: 'Student',
expiresAt, expiresAt,
courseIds courseIds,
programIds
} }
} }
]); ]);
@@ -542,7 +683,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
orgName: organization.name, orgName: organization.name,
inviteLink, inviteLink,
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel courseNames: accessNamesLabel
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
}); });
@@ -633,10 +774,113 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404); throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
} }
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds); const courseIds = data.courseIds ?? [];
if (courseGroups.length === 0) { const programIds = data.programIds ?? [];
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
let assignedToCourses = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
let assignedToPrograms = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
if (courseIds.length > 0) {
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
}
assignedToCourses = await enrollAudienceStudentProfilesInCourses(
orgId,
organization,
data.profileIds,
courseIds,
data.sendEmail
);
} }
return enrollAudienceStudentProfilesInCourses(orgId, organization, data.profileIds, data.courseIds, data.sendEmail); if (programIds.length > 0) {
const programs = await getProgramsByOrg(orgId, programIds);
if (programs.length === 0) {
throw new AppError('No valid programs found', ErrorCodes.VALIDATION_ERROR, 400, 'programIds');
}
assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
data.profileIds,
programIds,
data.sendEmail
);
}
return {
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToPrograms.alreadyEnrolled,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent
};
}
export async function updatePendingAudienceMemberEmail(
orgId: string,
memberId: number,
data: { email: string; sendEmail: boolean },
invitedByProfileId: string
) {
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const existingMember = await getOrganizationAudienceMember(orgId, memberId);
if (!existingMember) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
if (existingMember.profileId) {
throw new AppError(
'Only pending audience members can be updated through this endpoint',
ErrorCodes.VALIDATION_ERROR,
400,
'memberId'
);
}
const normalizedEmail = data.email.toLowerCase().trim();
const currentEmail = existingMember.email.toLowerCase().trim();
if (!normalizedEmail) {
throw new AppError('Email is required', ErrorCodes.VALIDATION_ERROR, 400, 'email');
}
if (normalizedEmail !== currentEmail) {
const matchingMembers = await getOrganizationMembersByNormalizedEmails(orgId, [normalizedEmail]);
if (matchingMembers.length > 0) {
throw new AppError('An audience member with this email already exists', ErrorCodes.CONFLICT, 409, 'email');
}
}
await revokeActiveOrganizationInvitesByEmails(orgId, [currentEmail, normalizedEmail], invitedByProfileId);
const updatedMember = await updateOrganizationAudienceMember(orgId, memberId, {
email: normalizedEmail,
verified: false
});
if (!updatedMember) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, currentEmail);
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: [normalizedEmail],
courseIds: meta.courseIds?.filter(Boolean) ?? [],
programIds: meta.programIds?.filter(Boolean) ?? [],
accessNamesLabel: undefined,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
return updatedMember;
} }
@@ -16,6 +16,7 @@ import {
MCP_TOOL_CREDIT_COST, MCP_TOOL_CREDIT_COST,
type TMcpToolName type TMcpToolName
} from '@cio/utils/plans'; } from '@cio/utils/plans';
import { PLAN } from '@cio/utils/plans/constants';
export type OrganizationAutomationUsageSummary = { export type OrganizationAutomationUsageSummary = {
type: TOrganizationApiKeyType; type: TOrganizationApiKeyType;
@@ -56,11 +57,16 @@ export async function assertOrganizationAutomationKeyCreationAllowed(
organizationId: string, organizationId: string,
type: TOrganizationApiKeyType type: TOrganizationApiKeyType
): Promise<void> { ): Promise<void> {
const planName = await getOrganizationPlanName(organizationId);
if (type === AUTOMATION_TYPE.API && planName === PLAN.BASIC) {
throw new AppError('Public API keys require an Early Adopter or Enterprise plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
if (type !== AUTOMATION_TYPE.MCP) { if (type !== AUTOMATION_TYPE.MCP) {
return; return;
} }
const planName = await getOrganizationPlanName(organizationId);
const limits = getMcpAutomationLimits(planName); const limits = getMcpAutomationLimits(planName);
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type); const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
+235 -1
View File
@@ -7,18 +7,20 @@ import {
createOrganizationInvite, createOrganizationInvite,
createOrganizationInviteAudit, createOrganizationInviteAudit,
createOrganizationMembers, createOrganizationMembers,
getActivePendingOrgInviteForEmail,
getOrganizationById, getOrganizationById,
getOrganizationInviteByTokenHash, getOrganizationInviteByTokenHash,
revokeActiveOrganizationInvitesByEmails revokeActiveOrganizationInvitesByEmails
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course'; import { getCourseGroupIds } from '@cio/db/queries/course';
import { enrollUsersInCourseGroups } from '@cio/db/queries/group'; import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org'; import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email'; import { sendEmail } from '@cio/email';
@@ -426,6 +428,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
if (invite) { if (invite) {
const metadata = invite.invite.metadata; const metadata = invite.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata); const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) { if (courseIds.length > 0) {
try { try {
@@ -441,6 +444,30 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
console.error('acceptOrganizationInvite course enrollment error:', error); console.error('acceptOrganizationInvite course enrollment error:', error);
} }
} }
if (programIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({
programId,
roleId: invite.invite.roleId,
profileId: user.id,
email: normalizedEmail
})
)
);
} catch (error) {
console.error('acceptOrganizationInvite program enrollment error:', error);
}
}
} }
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org'; const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
@@ -452,3 +479,210 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
redirectTo redirectTo
}; };
} }
/**
* Returns the pending org invite for the currently logged-in user, if one exists.
* Used by the LMS dashboard to prompt the student to accept on first load.
*/
export async function getPendingOrgInviteForUser(orgId: string, email: string) {
if (!orgId || !email) {
return null;
}
const data = await getActivePendingOrgInviteForEmail(orgId, email);
if (!data) {
return null;
}
return {
id: data.invite.id,
email: data.invite.email,
roleId: data.invite.roleId,
roleLabel: getRoleLabel(data.invite.roleId),
expiresAt: data.invite.expiresAt,
organization: data.organization
};
}
/**
* Accepts an organization invite by invite ID (for logged-in students with a pending invite).
* Replicates the acceptance logic of acceptOrganizationInvite but looks up by invite ID instead of token.
*/
export async function acceptOrganizationInviteById(
inviteId: string,
user: TAuthUser,
context: TInviteRequestContext = {}
) {
if (!user.id || !user.email) {
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
}
const normalizedEmail = user.email.toLowerCase().trim();
const result = await db.transaction(async (tx) => {
const [row] = await tx
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.id, inviteId))
.limit(1);
if (!row) {
throw new AppError('Invalid invite', ErrorCodes.NOT_FOUND, 404);
}
const status = getInviteStatus(row.invite);
if (status === 'REVOKED') {
throw new AppError('This invite has been revoked', ErrorCodes.UNAUTHORIZED, 403);
}
if (status === 'EXPIRED') {
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
}
if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
}
if (status === 'ACCEPTED') {
await markUserAndProfileEmailVerified(user.id, tx);
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: true };
}
const [orgMemberByEmail] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.email, normalizedEmail)
)
)
.limit(1);
if (orgMemberByEmail) {
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
}
await tx
.update(schema.organizationmember)
.set({ profileId: user.id, roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
} else {
const [orgMemberByProfile] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.profileId, user.id)
)
)
.limit(1);
if (orgMemberByProfile) {
await tx
.update(schema.organizationmember)
.set({ roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
} else {
await tx.insert(schema.organizationmember).values({
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
profileId: user.id,
email: normalizedEmail,
verified: true
});
}
}
await markUserAndProfileEmailVerified(user.id, tx);
const [acceptedInvite] = await tx
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId: user.id,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.id, row.invite.id),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning({ id: schema.organizationInvite.id });
if (!acceptedInvite) {
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
}
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: false };
});
await recordOrganizationInviteAudit(result.invite.id, result.invite.organizationId, 'ACCEPTED', {
actorProfileId: user.id,
targetEmail: normalizedEmail,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
metadata: { alreadyAccepted: result.alreadyAccepted }
});
const metadata = result.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
await enrollUsersInCourseGroups(
validGroupIds,
[{ profileId: user.id, email: normalizedEmail }],
result.invite.roleId
);
} catch (error) {
console.error('acceptOrganizationInviteById course enrollment error:', error);
}
}
if (programIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
)
);
} catch (error) {
console.error('acceptOrganizationInviteById program enrollment error:', error);
}
}
const siteName = result.organization.siteName || '';
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
return {
organizationId: result.organization.id,
roleId: result.roleId,
alreadyAccepted: result.alreadyAccepted,
redirectTo
};
}
+580
View File
@@ -0,0 +1,580 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
type TCreateProgram,
type TUpdateProgram,
type TAddCourseToProgram,
type TAddProgramMembers,
type TUpdateProgramMember,
type TCreateProgramNewsfeed,
type TUpdateProgramNewsfeed,
type TUpdateProgramReaction,
type TCreateProgramNewsfeedComment
} from '@cio/utils/validation/program';
import {
addCourseToProgram,
createProgramWithCreatorMembership,
createProgramNewsfeed as createProgramNewsfeedQuery,
createProgramNewsfeedComment as createProgramNewsfeedCommentQuery,
deleteProgram as deleteProgramQuery,
deleteProgramNewsfeed as deleteProgramNewsfeedQuery,
deleteProgramNewsfeedComment as deleteProgramNewsfeedCommentQuery,
getEnrolledProgramsByProfile,
getProgramById,
getProgramMemberByProfileId,
getProgramMembers,
getProgramNewsfeed,
getProgramNewsfeedById,
getProgramNewsfeedCommentById,
getProgramNewsfeedComments,
getProgramsByOrg,
getCoursesByProgram,
getProgramMemberRole,
isProgramCourse,
isProgramMember,
removeCourseFromProgram,
removeProgramMember,
updateProgram as updateProgramQuery,
updateProgramMember as updateProgramMemberQuery,
updateProgramNewsfeed as updateProgramNewsfeedQuery,
updateProgramNewsfeedReaction
} from '@cio/db/queries/program';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { getProfileByEmail } from '@cio/db/queries/auth';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import * as schema from '@db/schema';
type ProgramMemberEnrollment = {
profileId: string;
email: string | null;
roleId: number;
};
async function enrollProgramStudentsInGroups(
organizationId: string,
groupIds: string[],
members: ProgramMemberEnrollment[]
) {
const studentMembers = members.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
const uniqueGroupIds = [...new Set(groupIds)];
if (studentMembers.length === 0 || uniqueGroupIds.length === 0) {
return 0;
}
const organizationMemberRows = studentMembers.map((member) => ({
organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: member.email?.trim() || undefined,
verified: true
}));
const groupMemberRows = uniqueGroupIds.flatMap((groupId) =>
studentMembers.map((member) => ({
groupId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: member.email?.trim() || undefined
}))
);
await db.transaction(async (tx) => {
await tx.insert(schema.organizationmember).values(organizationMemberRows).onConflictDoNothing();
await tx.insert(schema.groupmember).values(groupMemberRows).onConflictDoNothing();
});
return groupMemberRows.length;
}
// ─── Program CRUD ────────────────────────────────────────────────────────────
export async function createProgram(organizationId: string, profileId: string, data: TCreateProgram) {
try {
const description = data.description?.trim() || `A program for ${data.name}`;
return createProgramWithCreatorMembership(
{
organizationId,
createdByProfileId: profileId,
name: data.name,
description,
coverImage: data.coverImage
},
profileId
);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program',
ErrorCodes.PROGRAM_CREATE_FAILED,
500
);
}
}
export async function getProgram(programId: string) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return program;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to get program',
ErrorCodes.PROGRAM_NOT_FOUND,
500
);
}
}
export async function listOrgPrograms(organizationId: string) {
try {
return getProgramsByOrg(organizationId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list programs',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgram(programId: string, data: TUpdateProgram) {
try {
const updated = await updateProgramQuery(programId, data);
if (!updated) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return updated;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program',
ErrorCodes.PROGRAM_UPDATE_FAILED,
500
);
}
}
export async function deleteProgram(programId: string) {
try {
const deleted = await deleteProgramQuery(programId);
if (!deleted) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program',
ErrorCodes.PROGRAM_DELETE_FAILED,
500
);
}
}
// ─── Program Members ─────────────────────────────────────────────────────────
export async function listProgramMembers(programId: string) {
try {
return getProgramMembers(programId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program members',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function addProgramMembers(programId: string, data: TAddProgramMembers) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
const programCourses = await getCoursesByProgram(programId);
const courseGroupIds = (await getCourseGroupIds(programCourses.map((course) => course.course.id))).map(
(courseGroup) => courseGroup.groupId
);
const results = await Promise.allSettled(
data.members.map(async ({ profileId: providedProfileId, email, roleId }) => {
const profile = !providedProfileId && email ? await getProfileByEmail(email) : null;
const profileId = providedProfileId ?? profile?.id ?? null;
const normalizedEmail = email?.toLowerCase().trim() ?? profile?.email ?? null;
if (profileId) {
const existing = await getProgramMemberByProfileId(programId, profileId);
if (existing) {
throw new AppError(
`${email} is already a member of this program`,
ErrorCodes.MEMBER_ALREADY_IN_PROGRAM,
409
);
}
}
if (!profileId && !email) {
throw new AppError(
'Either profileId or email must be provided for each member',
ErrorCodes.VALIDATION_ERROR,
400
);
}
return db.transaction(async (tx) => {
const [member] = await tx
.insert(schema.programMember)
.values({
programId,
profileId,
roleId,
email: normalizedEmail ?? undefined
})
.returning();
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
await tx
.insert(schema.organizationmember)
.values({
organizationId: program.organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined,
verified: true
})
.onConflictDoNothing();
await tx
.insert(schema.groupmember)
.values(
validCourseGroupIds.map((groupId) => ({
groupId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined
}))
)
.onConflictDoNothing();
}
return member;
});
})
);
const added = results
.filter((r) => r.status === 'fulfilled')
.map((r) => (r as PromiseFulfilledResult<unknown>).value);
const errors = results
.filter((r) => r.status === 'rejected')
.map((r) => (r as PromiseRejectedResult).reason?.message || 'Unknown error');
return { added, errors };
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to add program members',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function removeProgramMemberService(_programId: string, memberId: string) {
try {
const deleted = await removeProgramMember(memberId);
if (!deleted) {
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to remove program member',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramMemberService(_programId: string, memberId: string, data: TUpdateProgramMember) {
try {
const updated = await updateProgramMemberQuery(memberId, { roleId: data.roleId });
if (!updated) {
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
}
return updated;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program member',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function getEnrolledPrograms(profileId: string) {
try {
return getEnrolledProgramsByProfile(profileId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to get enrolled programs',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Program Courses ─────────────────────────────────────────────────────────
export async function listProgramCourses(programId: string, profileId: string) {
try {
const roleId = await getProgramMemberRole(programId, profileId);
const onlyPublished = roleId === ROLE.STUDENT;
return getCoursesByProgram(programId, onlyPublished);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program courses',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function addCourseToProgramService(programId: string, data: TAddCourseToProgram) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
const alreadyAdded = await isProgramCourse(programId, data.courseId);
if (alreadyAdded) {
throw new AppError('Course is already in this program', ErrorCodes.COURSE_ALREADY_IN_PROGRAM, 409);
}
const result = await addCourseToProgram(programId, data.courseId);
const students = (await getProgramMembers(programId))
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
.map((member) => ({
profileId: member.profileId!,
email: member.email ?? null,
roleId: member.roleId
}));
const courseGroupIds = (await getCourseGroupIds([data.courseId]))
.map((courseGroup) => courseGroup.groupId)
.filter((groupId): groupId is string => Boolean(groupId));
if (students.length > 0 && courseGroupIds.length > 0) {
await enrollProgramStudentsInGroups(program.organizationId, courseGroupIds, students);
}
return result;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to add course to program',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function removeCourseFromProgramService(programId: string, courseId: string) {
try {
const deleted = await removeCourseFromProgram(programId, courseId);
if (!deleted) {
throw new AppError('Course not found in program', ErrorCodes.NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to remove course from program',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Program Newsfeed ────────────────────────────────────────────────────────
export async function listProgramNewsfeed(programId: string, options: { cursor?: string; limit: number }) {
try {
return getProgramNewsfeed(programId, options);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createProgramNewsfeedService(programId: string, profileId: string, data: TCreateProgramNewsfeed) {
try {
const member = await getProgramMemberByProfileId(programId, profileId);
if (!member) {
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
}
return createProgramNewsfeedQuery({
programId,
authorId: member.id,
content: data.content,
isPinned: data.isPinned ?? false
});
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramNewsfeedService(feedId: string, data: TUpdateProgramNewsfeed) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return updateProgramNewsfeedQuery(feedId, data);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramNewsfeedReactionService(feedId: string, data: TUpdateProgramReaction) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return updateProgramNewsfeedReaction(feedId, data.reaction);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program newsfeed reaction',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteProgramNewsfeedService(feedId: string) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return deleteProgramNewsfeedQuery(feedId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
export async function listProgramNewsfeedComments(feedId: string) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return getProgramNewsfeedComments(feedId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program newsfeed comments',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createProgramNewsfeedCommentService(
feedId: string,
profileId: string,
data: TCreateProgramNewsfeedComment
) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
const member = await getProgramMemberByProfileId(feed.programId!, profileId);
if (!member) {
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
}
return createProgramNewsfeedCommentQuery({
programNewsfeedId: feedId,
authorId: member.id,
content: data.content
});
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program newsfeed comment',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteProgramNewsfeedCommentService(commentId: number) {
try {
const comment = await getProgramNewsfeedCommentById(commentId);
if (!comment) {
throw new AppError('Comment not found', ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND, 404);
}
const feed = await getProgramNewsfeedById(comment.programNewsfeedId!);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return deleteProgramNewsfeedCommentQuery(commentId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program newsfeed comment',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Auth Helpers ────────────────────────────────────────────────────────────
async function assertProgramMember(programId: string, profileId: string) {
const member = await isProgramMember(programId, profileId);
if (!member) {
throw new AppError('You are not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
}
}
+64
View File
@@ -0,0 +1,64 @@
import type {
TPublicApiAssignAudienceCourses,
TPublicApiAudienceMemberParam,
TPublicApiAudienceQuery,
TPublicApiCreateAudience,
TPublicApiUpdateAudience
} from '@cio/utils/validation/public-api';
import {
assignAudienceToCourses,
importAudienceMembers,
updatePendingAudienceMemberEmail
} from '@api/services/organization/audience';
import { getOrgAudience, getOrgAudienceMember, removeAudienceMember } from '@api/services/organization';
import { AppError, ErrorCodes } from '@api/utils/errors';
export async function listAudienceService(orgId: string, query: TPublicApiAudienceQuery) {
return getOrgAudience(orgId, query);
}
export async function createAudienceService(orgId: string, actorId: string | null, payload: TPublicApiCreateAudience) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
return importAudienceMembers(
orgId,
{
recipientCsv: payload.email,
courseIds: payload.courseIds,
allCourses: false,
allPrograms: false,
sendEmail: payload.sendEmail
},
actorId
);
}
export async function getAudienceMemberService(orgId: string, params: TPublicApiAudienceMemberParam) {
return getOrgAudienceMember(orgId, params.memberId);
}
export async function assignAudienceCoursesService(orgId: string, payload: TPublicApiAssignAudienceCourses) {
return assignAudienceToCourses(orgId, payload);
}
export async function removeAudienceService(orgId: string, memberId: number, actorId?: string | null) {
return removeAudienceMember(orgId, memberId, actorId ?? undefined);
}
export async function updateAudienceService(
orgId: string,
memberId: number,
actorId: string | null,
payload: TPublicApiUpdateAudience
) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
await updatePendingAudienceMemberEmail(orgId, memberId, payload, actorId);
return getOrgAudienceMember(orgId, memberId);
}
+135
View File
@@ -0,0 +1,135 @@
import type {
TPublicApiCourseParam,
TPublicApiCoursesQuery,
TPublicApiCreateCourse,
TPublicApiUpdateCourse,
TPublicApiUpdateCourseStructure
} from '@cio/utils/validation/public-api';
import { ROLE } from '@cio/utils/constants';
import {
createCourse as createCourseService,
deleteCourse as deleteCourseService,
getCourse,
updateCourse as updateCourseService
} from '@api/services/course/course';
import {
createCourseImportDraftService,
getCourseImportStructureService,
publishCourseImportDraftToExistingCourseService
} from '@api/services/course-import/course-import';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationCourses } from '@api/services/organization';
import { listCourseMembers } from '@api/services/course/people';
import { AppError, ErrorCodes } from '@api/utils/errors';
async function assertCourseBelongsToOrganization(orgId: string, courseId: string): Promise<void> {
const courseOrganizationId = await getCourseOrganizationId(courseId);
if (!courseOrganizationId || courseOrganizationId !== orgId) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
}
export async function listCoursesService(orgId: string, query: TPublicApiCoursesQuery) {
const result = await getOrganizationCourses(orgId, '', ROLE.ADMIN, {
page: 1,
limit: 100,
search: undefined,
tags: query.tags
});
return result.items;
}
export async function getCourseService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return getCourse(params.courseId);
}
export async function listCourseStudentsService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
const members = await listCourseMembers(params.courseId);
return members.filter((member) => member.roleId === ROLE.STUDENT);
}
export async function exportCourseService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return getCourseImportStructureService(orgId, params.courseId);
}
export async function createPublicApiCourseService(
orgId: string,
actorId: string | null,
payload: TPublicApiCreateCourse
) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
return createCourseService(actorId, {
...payload,
organizationId: orgId
});
}
export async function updatePublicApiCourseService(
orgId: string,
params: TPublicApiCourseParam,
payload: TPublicApiUpdateCourse
) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return updateCourseService(params.courseId, payload);
}
export async function deletePublicApiCourseService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return deleteCourseService(params.courseId);
}
export async function updatePublicApiCourseStructureService(
orgId: string,
actorId: string | null,
params: TPublicApiCourseParam,
payload: TPublicApiUpdateCourseStructure
) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
await assertCourseBelongsToOrganization(orgId, params.courseId);
const draft = await createCourseImportDraftService(orgId, actorId, {
sourceType: 'course',
idempotencyKey: payload.idempotencyKey,
summary: {
sourceCourseId: params.courseId,
syncMode: payload.mode,
...(payload.summary ?? {})
},
sourceArtifacts: [
{
type: 'course',
courseId: params.courseId,
label: payload.draft.course.title
},
...(payload.sourceArtifacts ?? [])
],
draft: payload.draft
});
const result = await publishCourseImportDraftToExistingCourseService(orgId, draft.id, {
courseId: params.courseId,
syncMode: payload.mode
});
return {
draftId: draft.id,
syncMode: payload.mode,
...result
};
}
+4 -3
View File
@@ -3,9 +3,10 @@ export interface TopCourse {
title: string; title: string;
enrollments: number; enrollments: number;
completion: number; completion: number;
certification: number;
} }
export interface Enrollment { export interface RecentCertification {
id: string; id: string;
avatarUrl: string | null; avatarUrl: string | null;
name: string; name: string;
@@ -15,9 +16,9 @@ export interface Enrollment {
} }
export interface OrganisationAnalytics { export interface OrganisationAnalytics {
revenue: number; totalCertificates: number;
numberOfCourses: number; numberOfCourses: number;
totalStudents: number; totalStudents: number;
topCourses: TopCourse[]; topCourses: TopCourse[];
enrollments: Enrollment[]; recentCertifications: RecentCertification[];
} }
+4 -140
View File
@@ -1,6 +1,10 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'; import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { Context } from 'hono'; import type { Context } from 'hono';
import { ErrorCodes, type ErrorCode } from '@cio/utils/constants';
export { ErrorCodes, type ErrorCode };
/** /**
* Standard application error with code for easy identification * Standard application error with code for easy identification
*/ */
@@ -26,146 +30,6 @@ export interface ErrorResponse {
field?: string; field?: string;
} }
// Error codes constants for consistency
export const ErrorCodes = {
// Common errors
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
// Dashboard Analytics errors
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
// Community errors
UPDATE_COMMUNITY_QUESTION_FAILED: 'UPDATE_COMMUNITY_QUESTION_FAILED',
ADD_COMMUNITY_QUESTION_FAILED: 'ADD_COMMUNITY_QUESTION_FAILED',
COMMUNITY_QUESTIONS_FETCH_FAILED: 'COMMUNITY_QUESTIONS_FETCH_FAILED',
COMMUNITY_QUESTION_NOT_FOUND: 'COMMUNITY_QUESTION_NOT_FOUND',
COMMENT_SUBMISSION_FAILED: 'COMMENT_SUBMISSION_FAILED',
UPVOTE_FAILED: 'UPVOTE_FAILED',
DELETE_COMMENT_FAILED: 'DELETE_COMMENT_FAILED',
DELETE_QUESTION_FAILED: 'DELETE_QUESTION_FAILED',
// Organization errors
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
ORG_NOT_FOUND: 'ORG_NOT_FOUND',
ORGANIZATION_NOT_FOUND: 'ORGANIZATION_NOT_FOUND',
ORG_TEAM_FETCH_FAILED: 'ORG_TEAM_FETCH_FAILED',
ORG_AUDIENCE_FETCH_FAILED: 'ORG_AUDIENCE_FETCH_FAILED',
COURSES_FETCH_FAILED: 'COURSES_FETCH_FAILED',
ORG_PLAN_CREATE_FAILED: 'ORG_PLAN_CREATE_FAILED',
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_AUDIENCE_REMOVE_FAILED: 'ORG_AUDIENCE_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
ORGANIZATION_API_KEY_NOT_FOUND: 'ORGANIZATION_API_KEY_NOT_FOUND',
ORGANIZATION_API_KEY_CREATE_FAILED: 'ORGANIZATION_API_KEY_CREATE_FAILED',
ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED',
AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED',
AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED',
AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED',
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
TAG_CREATE_FAILED: 'TAG_CREATE_FAILED',
TAG_UPDATE_FAILED: 'TAG_UPDATE_FAILED',
TAG_ASSIGNMENT_FAILED: 'TAG_ASSIGNMENT_FAILED',
// Asset errors
ASSET_NOT_FOUND: 'ASSET_NOT_FOUND',
ASSET_IN_USE: 'ASSET_IN_USE',
ASSET_USAGE_NOT_FOUND: 'ASSET_USAGE_NOT_FOUND',
ASSET_CREATE_FAILED: 'ASSET_CREATE_FAILED',
ASSET_FETCH_FAILED: 'ASSET_FETCH_FAILED',
ASSET_LIST_FAILED: 'ASSET_LIST_FAILED',
ASSET_UPDATE_FAILED: 'ASSET_UPDATE_FAILED',
ASSET_DELETE_FAILED: 'ASSET_DELETE_FAILED',
ASSET_ATTACH_FAILED: 'ASSET_ATTACH_FAILED',
ASSET_DETACH_FAILED: 'ASSET_DETACH_FAILED',
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',
ASSET_STORAGE_FETCH_FAILED: 'ASSET_STORAGE_FETCH_FAILED',
// Profile errors
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
// Account errors
ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND',
// Auth errors
FORBIDDEN: 'FORBIDDEN',
// Course errors
COURSE_NOT_FOUND: 'COURSE_NOT_FOUND',
COURSE_FETCH_FAILED: 'COURSE_FETCH_FAILED',
COURSE_IMPORT_DRAFT_NOT_FOUND: 'COURSE_IMPORT_DRAFT_NOT_FOUND',
COURSE_IMPORT_CREATE_FAILED: 'COURSE_IMPORT_CREATE_FAILED',
COURSE_IMPORT_FETCH_FAILED: 'COURSE_IMPORT_FETCH_FAILED',
COURSE_IMPORT_UPDATE_FAILED: 'COURSE_IMPORT_UPDATE_FAILED',
COURSE_IMPORT_PUBLISH_FAILED: 'COURSE_IMPORT_PUBLISH_FAILED',
// Lesson errors
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',
LESSON_FETCH_FAILED: 'LESSON_FETCH_FAILED',
COURSE_SECTION_NOT_FOUND: 'COURSE_SECTION_NOT_FOUND',
// Exercise errors
EXERCISE_NOT_FOUND: 'EXERCISE_NOT_FOUND',
EXERCISE_FETCH_FAILED: 'EXERCISE_FETCH_FAILED',
// Submission errors
SUBMISSION_NOT_FOUND: 'SUBMISSION_NOT_FOUND',
SUBMISSION_FETCH_FAILED: 'SUBMISSION_FETCH_FAILED',
// Comment errors
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
LESSON_COMMENT_UPDATE_FAILED: 'LESSON_COMMENT_UPDATE_FAILED',
NEWSFEED_COMMENT_UPDATE_FAILED: 'NEWSFEED_COMMENT_UPDATE_FAILED',
// Quiz errors
QUIZ_NOT_FOUND: 'QUIZ_NOT_FOUND',
QUIZ_FETCH_FAILED: 'QUIZ_FETCH_FAILED',
QUIZ_CREATE_FAILED: 'QUIZ_CREATE_FAILED',
QUIZ_UPDATE_FAILED: 'QUIZ_UPDATE_FAILED',
QUIZ_DELETE_FAILED: 'QUIZ_DELETE_FAILED',
// Lesson Language errors
LESSON_LANGUAGE_NOT_FOUND: 'LESSON_LANGUAGE_NOT_FOUND',
LESSON_LANGUAGE_FETCH_FAILED: 'LESSON_LANGUAGE_FETCH_FAILED',
LESSON_LANGUAGE_CREATE_FAILED: 'LESSON_LANGUAGE_CREATE_FAILED',
LESSON_LANGUAGE_UPDATE_FAILED: 'LESSON_LANGUAGE_UPDATE_FAILED',
// SSO errors
SSO_REGISTRATION_FAILED: 'SSO_REGISTRATION_FAILED',
SSO_NOT_FOUND: 'SSO_NOT_FOUND',
SSO_NOT_ENABLED: 'SSO_NOT_ENABLED',
UPGRADE_REQUIRED: 'UPGRADE_REQUIRED',
CONFLICT: 'CONFLICT',
// Token exchange errors
TOKEN_EXCHANGE_INVALID_TOKEN: 'TOKEN_EXCHANGE_INVALID_TOKEN',
TOKEN_EXCHANGE_EXPIRED: 'TOKEN_EXCHANGE_EXPIRED',
TOKEN_EXCHANGE_ORG_NOT_FOUND: 'TOKEN_EXCHANGE_ORG_NOT_FOUND',
TOKEN_EXCHANGE_NOT_ENABLED: 'TOKEN_EXCHANGE_NOT_ENABLED',
// Token auth admin errors
TOKEN_AUTH_ALREADY_EXISTS: 'TOKEN_AUTH_ALREADY_EXISTS',
// Generic
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR'
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
/** /**
* Handles errors consistently across all routes * Handles errors consistently across all routes
* @param c - Hono context * @param c - Hono context
+1
View File
@@ -11,6 +11,7 @@ type LessonDocument = NonNullable<TLesson['documents']>[number];
function mapProviderToVideoType(provider: string): LessonVideo['type'] { function mapProviderToVideoType(provider: string): LessonVideo['type'] {
if (provider === 'youtube') return 'youtube'; if (provider === 'youtube') return 'youtube';
if (provider === 'google_drive') return 'google_drive';
if (provider === 'generic' || provider === 'external_url') return 'generic'; if (provider === 'generic' || provider === 'external_url') return 'generic';
return 'upload'; return 'upload';
} }
+14 -3
View File
@@ -1,5 +1,4 @@
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */ function parseInviteIds(metadata: unknown, keys: string[]): string[] {
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
let obj: Record<string, unknown> | null = null; let obj: Record<string, unknown> | null = null;
if (metadata == null) { if (metadata == null) {
return []; return [];
@@ -17,9 +16,21 @@ export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
if (!obj) { if (!obj) {
return []; return [];
} }
const raw = obj.courseIds ?? obj.course_ids;
const raw = keys.map((key) => obj[key]).find((value) => value !== undefined);
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return []; return [];
} }
return raw.filter((id): id is string => typeof id === 'string' && id.length > 0); return raw.filter((id): id is string => typeof id === 'string' && id.length > 0);
} }
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
return parseInviteIds(metadata, ['courseIds', 'course_ids']);
}
/** Normalize JSONB organization-invite metadata for program ids (matches importAudienceMembers storage). */
export function parseProgramIdsFromInviteMetadata(metadata: unknown): string[] {
return parseInviteIds(metadata, ['programIds', 'program_ids']);
}
+18 -3
View File
@@ -3,8 +3,17 @@
* These functions help create consistent and secure rate limit keys * These functions help create consistent and secure rate limit keys
*/ */
import { getConnInfo } from '@hono/node-server/conninfo';
import type { Context } from 'hono'; import type { Context } from 'hono';
function normalizeIp(value: string | null | undefined): string | null {
const ip = value?.trim();
if (!ip) return null;
return ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip;
}
/** /**
* Extract real client IP address considering various proxy headers * Extract real client IP address considering various proxy headers
*/ */
@@ -15,9 +24,11 @@ export const extractClientIp = (c: Context): string => {
// 3. X-Forwarded-For (standard, but can be spoofed) // 3. X-Forwarded-For (standard, but can be spoofed)
// 4. Connection IP (least reliable) // 4. Connection IP (least reliable)
const cfConnectingIp = c.req.header('cf-connecting-ip'); const cfConnectingIp = normalizeIp(c.req.header('cf-connecting-ip'));
const realIp = c.req.header('x-real-ip'); const realIp = normalizeIp(c.req.header('x-real-ip'));
const forwardedFor = c.req.header('x-forwarded-for'); const forwardedFor = c.req.header('x-forwarded-for');
const connInfo = getConnInfo(c);
const remoteAddress = normalizeIp(connInfo.remote.address);
if (cfConnectingIp) { if (cfConnectingIp) {
return cfConnectingIp; return cfConnectingIp;
@@ -30,7 +41,11 @@ export const extractClientIp = (c: Context): string => {
if (forwardedFor) { if (forwardedFor) {
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2" // x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
// The first IP is usually the original client // The first IP is usually the original client
return forwardedFor.split(',')[0].trim(); return normalizeIp(forwardedFor.split(',')[0]) ?? 'unknown';
}
if (remoteAddress) {
return remoteAddress;
} }
// Fallback - this might be a proxy IP // Fallback - this might be a proxy IP
-1
View File
@@ -2,7 +2,6 @@
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "new-york", "style": "new-york",
"tailwind": { "tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css", "css": "src/app.css",
"baseColor": "zinc" "baseColor": "zinc"
}, },
+7 -7
View File
@@ -19,26 +19,26 @@
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"autoprefixer": "^10.4.20", "@tailwindcss/postcss": "^4.2.2",
"bits-ui": "^0.21.16", "bits-ui": "^2.16.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"postcss": "^8.5.6",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwind-variants": "^0.3.0", "tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.9", "tailwindcss": "^4.2.2",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^2.0.4" "vitest": "^2.0.4"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/container-queries": "^0.1.1", "@cio/ui": "workspace:*",
"@tailwindcss/forms": "^0.5.9", "@lucide/svelte": "^0.562.0",
"@tailwindcss/typography": "^0.5.15",
"lucide-svelte": "^0.468.0", "lucide-svelte": "^0.468.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"posthog-js": "^1.180.1", "posthog-js": "^1.180.1",
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {}
autoprefixer: {}
} }
}; };
+63 -3
View File
@@ -1,6 +1,66 @@
@tailwind base; @import 'tailwindcss';
@tailwind components; @import '@cio/ui/output.css';
@tailwind utilities; @import '../../../packages/ui/src/custom/animation/marketing-layers.css';
@custom-variant dark (&:where(.dark, .dark *));
@source './**/*.{html,js,svelte,ts}';
@source '../../../packages/ui/src/custom/animation/**/*.svelte';
/* Theme tokens (colors, fonts, radii, motion) + 2xl breakpoint for layout (e.g. `max-w-(--breakpoint-2xl)`). */
@theme inline {
--breakpoint-2xl: 1400px;
--font-sans:
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-drawn:
Virgil, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--color-border: hsl(var(--border) / <alpha-value>);
--color-input: hsl(var(--input) / <alpha-value>);
--color-ring: hsl(var(--ring) / <alpha-value>);
--color-background: hsl(var(--background) / <alpha-value>);
--color-foreground: hsl(var(--foreground) / <alpha-value>);
--color-primary: hsl(var(--primary) / <alpha-value>);
--color-primary-foreground: hsl(var(--primary-foreground) / <alpha-value>);
--color-secondary: hsl(var(--secondary) / <alpha-value>);
--color-secondary-foreground: hsl(var(--secondary-foreground) / <alpha-value>);
--color-destructive: hsl(var(--destructive) / <alpha-value>);
--color-destructive-foreground: hsl(var(--destructive-foreground) / <alpha-value>);
--color-muted: hsl(var(--muted) / <alpha-value>);
--color-muted-foreground: hsl(var(--muted-foreground) / <alpha-value>);
--color-accent: hsl(var(--accent) / <alpha-value>);
--color-accent-foreground: hsl(var(--accent-foreground) / <alpha-value>);
--color-popover: hsl(var(--popover) / <alpha-value>);
--color-popover-foreground: hsl(var(--popover-foreground) / <alpha-value>);
--color-card: hsl(var(--card) / <alpha-value>);
--color-card-foreground: hsl(var(--card-foreground) / <alpha-value>);
--animate-border-beam: border-beam calc(var(--duration) * 1s) infinite linear;
--animate-meteor: meteor 5s linear infinite;
--animate-shimmer: shimmer 8s infinite;
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;
--animate-magicslide: magicslide var(--speed) ease-in-out infinite alternate;
}
/* Used by ShinnyText (`animate-shimmer`); marketing-layers uses `shimmer-marketing` for other UIs. */
@keyframes shimmer {
0%,
90%,
100% {
background-position: calc(-100% - var(--shimmer-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shimmer-width)) 0;
}
}
@font-face { @font-face {
font-family: 'Virgil'; font-family: 'Virgil';
@@ -1,36 +0,0 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { Motion } from "svelte-motion";
export let variant: {
hidden: { filter: string; opacity: number };
visible: { filter: string; opacity: number };
} = {
hidden: { filter: "blur(10px)", opacity: 0 },
visible: { filter: "blur(0px)", opacity: 1 },
};
export let duration: number = 1;
let className = "font-display text-4xl font-bold tracking-[-0.02em] drop-shadow-sm md:text-7xl md:leading-[5rem]";
export { className as class };
let defaultVariants = {
hidden: { filter: "blur(10px)", opacity: 0 },
visible: { filter: "blur(0px)", opacity: 1 },
};
let combinedVariants = variant || defaultVariants;
</script>
<Motion
initial="hidden"
animate="visible"
transition={{ duration }}
variants={combinedVariants}
let:motion
>
<h1
class={cn(className)}
use:motion
>
<slot />
</h1>
</Motion>
@@ -1,35 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
export let size = 200;
export let duration = 15;
export let anchor = 90;
export let borderWidth = 1.5;
export let colorFrom = '#ffaa40';
export let colorTo = '#9c40ff';
export let delay = 0;
let delaySec = delay + 's';
let className: any = '';
export { className as class };
</script>
<div
style:--border-width={borderWidth}
style:--size={size}
style:--color-from={colorFrom}
style:--color-to={colorTo}
style:--delay={delaySec}
style:--anchor={anchor}
style:--duration={duration}
class={cn(
'pointer-events-none absolute inset-[0] rounded-[inherit] [border:calc(var(--border-width)*1px)_solid_transparent]',
// mask styles
'![mask-clip:padding-box,border-box] ![mask-composite:intersect] [mask:linear-gradient(transparent,transparent),linear-gradient(white,white)]',
// pseudo styles
'after:animate-border-beam after:absolute after:aspect-square after:w-[calc(var(--size)*1px)] after:[animation-delay:var(--delay)] after:[background:linear-gradient(to_left,var(--color-from),var(--color-to),transparent)] after:[offset-anchor:calc(var(--anchor)*1%)_50%] after:[offset-path:rect(0_auto_auto_0_round_calc(var(--size)*1px))]',
className
)}
></div>
@@ -1,21 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
let { children, className } = $props();
// let cardContent = {
// title: 'Svelte is Vibe',
// description: 'Svelte is a fun way to build web applications.'
// };
</script>
<div class={cn('p-4 text-left', className)}>
<!-- <h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
{cardContent.title}
</h3>
<p class="text-gray-700 dark:text-gray-300">
{cardContent.description}
</p> -->
{@render children()}
</div>
@@ -1,13 +0,0 @@
<script>
let sharedClass =
'rounded-full outline outline-8 dark:outline-gray-950 sm:my-6 md:my-8 size-1 my-4 outline-gray-50 bg-green-400';
</script>
<div class="absolute z-0 grid h-full w-full items-center gap-8 lg:grid-cols-2">
<section class="absolute z-0 grid h-full w-full grid-cols-2 place-content-between">
<div class="{sharedClass} -mx-[2.5px]"></div>
<div class="{sharedClass} -mx-[2px] place-self-end"></div>
<div class="{sharedClass} -mx-[2.5px]"></div>
<div class="{sharedClass} -mx-[2px] place-self-end"></div>
</section>
</div>
@@ -1,37 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
export let width: string | number = 16;
export let height: string | number = 16;
export let x = 0;
export let y = 0;
export let cx = 1;
export let cy = 1;
export let cr = 1;
export let fillColor = 'rgb(163 163 163 / 0.8)';
let className: any = '';
export { className as class };
let id = crypto.randomUUID().toString().slice(0, 10); // generating a unique ID for Components
</script>
<svg
aria-hidden="true"
class={cn('pointer-events-none absolute inset-0 h-full w-full ', className)}
{...$$restProps}
fill={fillColor}
>
<defs>
<pattern
{id}
{width}
{height}
patternUnits="userSpaceOnUse"
patternContentUnits="userSpaceOnUse"
{x}
{y}
>
<circle id="pattern-circle" {cx} {cy} r={cr} />
</pattern>
</defs>
<rect width="100%" height="100%" stroke-width={0} fill={`url(#${id})`} />
</svg>
@@ -1,42 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { AnimatePresence, Motion } from 'svelte-motion';
let className: any = 'Gradual Spacing';
export { className as class };
export let words = 'Gradual Spacing';
export let duration = 0.5;
export let delayMultiple = 0.04;
export let framerProps = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 }
};
let wordsspilit = words.split('');
</script>
<div class="flex justify-center space-x-1">
<AnimatePresence let:item list={[{ key: wordsspilit }]}>
{#each wordsspilit as char, i}
<Motion
initial="hidden"
animate="visible"
exit="hidden"
variants={framerProps}
transition={{
duration: duration,
delay: i * delayMultiple
}}
let:motion
>
<span use:motion class={cn('drop-shadow-sm', className)}>
{#if char === ' '}
<span>&nbsp;</span>
{:else}
{char}
{/if}
</span>
</Motion>
{/each}
</AnimatePresence>
</div>
@@ -1,72 +0,0 @@
<script lang="ts">
import { Motion, useMotionValue, useMotionTemplate } from 'svelte-motion';
import { onMount } from 'svelte';
import { cn } from '$lib/utils';
export let gradientSize: number = 200;
export let gradientColor: string = '#262626';
export let gradientOpacity: number = 0.8;
let className: string = '';
export { className as class };
let gradSize = useMotionValue(gradientSize);
let gradColor = useMotionValue(gradientColor);
let mouseX = useMotionValue(-gradientSize);
let mouseY = useMotionValue(-gradientSize);
function handleMouseMove(e: MouseEvent) {
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
mouseX.set(e.clientX - rect.left);
mouseY.set(e.clientY - rect.top);
}
function handleMouseLeave() {
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
}
onMount(() => {
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
});
let bg = useMotionTemplate`radial-gradient(${gradSize}px circle at ${mouseX}px ${mouseY}px, ${gradColor}, transparent 100%)`;
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- I have added py-4 in below code, you can customize the component as per needs -->
<div
on:mousemove={handleMouseMove}
on:mouseleave={handleMouseLeave}
class={cn(
'group relative flex size-full justify-center overflow-hidden rounded-xl border bg-neutral-100 py-4 text-black dark:bg-neutral-900 dark:text-white',
className
)}
>
<div class="relative z-10">
<!-- Default -->
<slot>
<div class="flex h-full items-center justify-center text-center">
<p class="text-2xl">Magic Card</p>
</div>
</slot>
</div>
<Motion
style={{
background: bg,
opacity: gradientOpacity
}}
let:motion
>
<div
use:motion
class="pointer-events-none absolute -inset-px rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
/>
</Motion>
</div>
<style>
.size-full {
width: 100%;
height: 100%;
}
</style>
@@ -1,36 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { onMount } from 'svelte';
export let number = 10;
let meteorStyles: any = [];
let changeMeteors = (num: number) => {
meteorStyles = [];
const styles = [...new Array(num)].map(() => ({
top: -20,
left: Math.floor(Math.random() * 700) + 'px',
animationDelay: Math.random() * 1 + 0.2 + 's',
animationDuration: Math.floor(Math.random() * 8 + 2.9) + 's'
}));
meteorStyles = styles;
};
onMount(() => {
changeMeteors(number);
});
// $: changeMeteors(number);
</script>
{#each meteorStyles as style, idx}
<span
id="meteor-{idx + 1}"
class={cn(
'animate-meteor pointer-events-none absolute left-1/2 top-1/2 size-[2.4px] rotate-[215deg] rounded-full bg-slate-500 shadow-[0_0_0_1px_#ffffff10]'
)}
style="top: {style.top}px; left: {style.left}; animation-delay: {style.animationDelay}; animation-duration: {style.animationDuration};"
>
<!-- Meteor Tail -->
<div
class="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2 bg-gradient-to-r from-slate-500 via-blue-600/30 to-transparent"
/>
</span>
{/each}
@@ -1,67 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
export let href: string;
export let target: string = '';
export let rel: string = '';
export let shimmerColor = '#ffffff';
export let shimmerSize = '0.05em';
export let shimmerDuration = '3s';
export let borderRadius = '100px';
export let background = 'rgba(0, 0, 0, 1)';
let className: any = '';
export { className as class };
</script>
<a
style:--spread="90deg"
style:--shimmer-color={shimmerColor}
style:--radius={borderRadius}
style:--speed={shimmerDuration}
style:--cut={shimmerSize}
style:--bg={background}
class={cn(
'group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden whitespace-nowrap border border-white/10 px-6 py-3 text-white [background:var(--bg)] [border-radius:var(--radius)] dark:text-white',
'transform-gpu transition-transform duration-300 ease-in-out active:translate-y-[1px]',
className
)}
{href}
{target}
{rel}
data-rewardful
>
<div class={cn('-z-30 blur-[2px]', 'absolute inset-0 overflow-visible [container-type:size]')}>
<div
class="animate-magicslide absolute inset-0 h-[100cqh] [aspect-ratio:1] [border-radius:0] [mask:none]"
>
<!-- spark before -->
<div
class="animate-spin-around absolute inset-[-100%] w-auto rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))] [translate:0_0]"
/>
</div>
</div>
<slot>Shimmer Button</slot>
<!-- Highlight -->
<div
class={cn(
'insert-0 absolute h-full w-full',
'rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]',
// transition
'transform-gpu transition-all duration-300 ease-in-out',
// on hover
'group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
// on click
'group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
)}
></div>
<!-- backdrop -->
<div
class={cn(
'absolute -z-20 [background:var(--bg)] [border-radius:var(--radius)] [inset:var(--cut)]'
)}
></div>
</a>
@@ -1,24 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
export let shimmerWidth = 100;
let className: any = '';
export { className as class };
</script>
<p
style:--shimmer-width="{shimmerWidth}px"
class={cn(
'mx-auto max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
// Shimmer effect
'animate-shimmer bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shimmer-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
// Shimmer gradient
'bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80',
className
)}
>
<slot>Shimmer Animation</slot>
</p>
@@ -1,73 +0,0 @@
<script lang="ts">
interface TSparkle {
id: string;
x: string;
y: string;
color: string;
delay: number;
scale: number;
lifespan: number;
}
export let text: string = 'Hello World';
export let colors = {
first: '#9E7AFF',
second: '#FE8BBB'
};
export let sparklesCount = 5;
let className = '';
export { className as class };
let sparkles: TSparkle[] = [];
import { onMount, onDestroy } from 'svelte';
import Sparkle from './sparkle.svelte';
let generateStar = () => {
let starX = `${Math.random() * 100}%`;
let starY = `${Math.random() * 100}%`;
let color = Math.random() > 0.5 ? colors.first : colors.second;
let delay = Math.random() * 4;
let scale = Math.random() * 1 + 0.3;
let lifespan = Math.random() * 10 + 5;
let id = `${starX}-${starY}-${Date.now()}`;
return { id, x: starX, y: starY, color, delay, scale, lifespan };
};
let initializeStars = async () => {
let newSparkles = Array.from({ length: sparklesCount }, generateStar);
sparkles = newSparkles;
};
const updateStars = () => {
let temp = sparkles.map((star) => {
if (star.lifespan <= 0) {
return generateStar();
} else {
return { ...star, lifespan: star.lifespan - 0.1 };
}
});
sparkles = temp;
};
let interval: NodeJS.Timer;
onMount(async () => {
initializeStars();
interval = setInterval(updateStars, 100);
});
onDestroy(() => clearInterval(interval));
</script>
<div
style:--sparkles-first-color="{colors.first};"
style:--sparkles-second-color="{colors.second};"
class={className}
{...$$restProps}
>
<span class="relative inline-block">
{#each sparkles as item, _}
<Sparkle {...item} />
{/each}
<strong>{text}</strong>
</span>
</div>
@@ -1,35 +0,0 @@
<script lang="ts">
import { Motion } from "svelte-motion";
export let id;
export let x;
export let y;
export let color;
export let delay;
export let scale;
</script>
<Motion
initial={{ opacity: 0, left: x, top: y }}
animate={{
opacity: [0, 1, 0],
scale: [0, scale, 0],
rotate: [75, 120, 150],
}}
transition={{ duration: 0.8, repeat: Infinity, delay }}
let:motion
>
<svg
{id}
use:motion
class="pointer-events-none absolute z-20"
width="21"
height="21"
viewBox="0 0 21 21"
>
<path
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
fill={color}
/>
</svg>
</Motion>
@@ -1,9 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
let { children, className } = $props();
</script>
<div class={cn('text-left', className)}>
{@render children()}
</div>
@@ -1,21 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
let className: any = '';
export { className as class };
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width={24}
height={24}
stroke-width="1"
stroke-linecap="round"
stroke="currentColor"
class={cn('absolute text-black dark:text-white', className)}
{...$$restProps}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { buttonVariants } from '$lib/components/ui/button'; import { buttonVariants } from '@cio/ui/base/button';
import { ShimmerButton } from '$lib/components/animation/shimmerbutton'; import { ShimmerButton } from '@cio/ui/custom/animation';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '@cio/ui/base/dialog';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import BadgeCheck from 'lucide-svelte/icons/badge-check'; import BadgeCheck from 'lucide-svelte/icons/badge-check';
@@ -19,10 +19,6 @@
</script> </script>
<Dialog.Root> <Dialog.Root>
<!-- <span
class="absolute inset-0 z-30 flex items-center justify-center bg-black/50 opacity-0 transition-all duration-500 hover:opacity-80"
>
</span> -->
<Dialog.Trigger class={cn(buttonVariants({ variant: 'default' }), `z-20 ${ctaClass}`)}> <Dialog.Trigger class={cn(buttonVariants({ variant: 'default' }), `z-20 ${ctaClass}`)}>
{ctaLabel} {ctaLabel}
</Dialog.Trigger> </Dialog.Trigger>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { ShinnyText } from '$lib/components/animation/shinny-text'; import { ShinnyText } from '@cio/ui/custom/animation';
import Copy from 'lucide-svelte/icons/copy'; import Copy from 'lucide-svelte/icons/copy';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
@@ -17,7 +17,7 @@
)} )}
> >
<ShinnyText <ShinnyText
class="animate-gradient inline-flex items-center justify-center bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text px-4 py-1 text-transparent text-white transition ease-out hover:text-neutral-400 hover:duration-300" class="animate-gradient inline-flex items-center justify-center bg-linear-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-size-[var(--bg-size)_100%] bg-clip-text px-4 py-1 text-transparent text-white transition ease-out hover:text-neutral-400 hover:duration-300"
> >
<span>{command}</span> <span>{command}</span>
@@ -11,7 +11,7 @@
</script> </script>
<header <header
class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"
> >
<p class="bg-blue-700 py-1.5 text-center text-sm font-medium text-white"> <p class="bg-blue-700 py-1.5 text-center text-sm font-medium text-white">
brought to you by brought to you by
@@ -35,7 +35,7 @@
</svg> </svg>
</a> </a>
</p> </p>
<div class="container flex h-14 max-w-screen-2xl items-center"> <div class="flex h-14 max-w-5xl mx-auto items-center">
<div class="mr-4 flex"> <div class="mr-4 flex">
<a href="/" class="mr-6 flex items-center"> <a href="/" class="mr-6 flex items-center">
<img src="/logo-512.png" alt="Course App" class="h-8 w-8" /> <img src="/logo-512.png" alt="Course App" class="h-8 w-8" />
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Santa from './santa.svg'; import Santa from './santa.svg';
import { BorderBeam } from '$lib/components/animation/border-beam'; import { BorderBeam, DotPattern } from '@cio/ui/custom/animation';
import { DotPattern } from '$lib/components/animation/dot-pattern';
import { BuyTemplate } from '$lib/components/buy-template'; import { BuyTemplate } from '$lib/components/buy-template';
</script> </script>
@@ -13,7 +13,7 @@
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
class={cn( class={cn(
"bg-border shrink-0", "bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "min-h-full w-[1px]", orientation === "horizontal" ? "h-px w-full" : "min-h-full w-px",
className className
)} )}
{orientation} {orientation}
@@ -6,7 +6,7 @@
</script> </script>
<Sonner <Sonner
theme={$mode} theme={mode.current}
class="toaster group" class="toaster group"
toastOptions={{ toastOptions={{
classes: { classes: {
+13 -10
View File
@@ -1,16 +1,19 @@
<script lang="ts"> <script lang="ts">
import { CardBody, CardContainer } from '$lib/components/animation/card';
import { BlurFade } from '$lib/components/animation/blurfade';
import { BlurIn } from '$lib/components/animation/blurin';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { TemplateCard, CustomTemplate } from '$lib/components/template-card'; import { TemplateCard, CustomTemplate } from '$lib/components/template-card';
import { Meteors } from '$lib/components/animation/meteors';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { BorderBeam } from '$lib/components/animation/border-beam';
import { ShimmerButton } from '$lib/components/animation/shimmerbutton';
import { BuyTemplate } from '$lib/components/buy-template'; import { BuyTemplate } from '$lib/components/buy-template';
import { Sparkle } from '$lib/components/animation/sparkle'; import {
import { HeroVideoDialog } from '$lib/components/animation/hero-video-dialog'; BlurFade,
BlurIn,
BorderBeam,
CardBody,
CardContainer,
HeroVideoDialog,
Meteors,
ShimmerButton,
Sparkle
} from '@cio/ui/custom/animation';
const templates = [ const templates = [
{ {
@@ -93,7 +96,7 @@
className="py-10 max-w-4xl mx-auto flex flex-col items-center justify-center px-30 gap-2 md:gap-5" className="py-10 max-w-4xl mx-auto flex flex-col items-center justify-center px-30 gap-2 md:gap-5"
> >
<BlurIn <BlurIn
class="pointer-events-none flex max-w-2xl flex-col items-center whitespace-pre-wrap bg-gradient-to-b from-black to-gray-300/80 bg-clip-text text-center text-2xl font-semibold leading-none tracking-tighter text-transparent md:mb-4 md:block md:text-4xl lg:text-6xl" class="pointer-events-none flex max-w-2xl flex-col items-center whitespace-pre-wrap bg-linear-to-b from-black to-gray-300/80 bg-clip-text text-center text-2xl font-semibold leading-none tracking-tighter text-transparent md:mb-4 md:block md:text-4xl lg:text-6xl"
> >
Create a <span class="font-mono italic text-blue-700">Course</span> Create a <span class="font-mono italic text-blue-700">Course</span>
<span>Site for your Product in</span> <span>Site for your Product in</span>
@@ -157,7 +160,7 @@
<!-- Templates --> <!-- Templates -->
<section id="templates" class="my-5 w-full pt-0 md:my-0 md:pt-24"> <section id="templates" class="my-5 w-full pt-0 md:my-0 md:pt-24">
<div class="px-4 md:container"> <div class="px-4 max-w- mx-auto">
<h2 class="mb-5 text-center text-3xl font-semibold md:mb-20 md:text-start md:text-5xl"> <h2 class="mb-5 text-center text-3xl font-semibold md:mb-20 md:text-start md:text-5xl">
Browse Templates Browse Templates
</h2> </h2>
+7 -7
View File
@@ -1,23 +1,23 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-auto';
import path from 'path';
import { fileURLToPath } from 'url';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess({ preprocess: vitePreprocess({
script: true script: true
}), }),
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(), adapter: adapter(),
alias: { alias: {
$lib: './src/lib', $lib: path.resolve(__dirname, './src/lib'),
$lib: './src/lib/*' '@cio/ui': path.resolve(__dirname, './node_modules/@cio/ui/src'),
'@cio/ui/*': path.resolve(__dirname, './node_modules/@cio/ui/src/*')
} }
} }
}; };
-128
View File
@@ -1,128 +0,0 @@
import { fontFamily } from 'tailwindcss/defaultTheme';
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ['dark'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border) / <alpha-value>)',
input: 'hsl(var(--input) / <alpha-value>)',
ring: 'hsl(var(--ring) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)'
},
secondary: {
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)'
},
destructive: {
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)'
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)'
},
accent: {
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)'
},
popover: {
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)'
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
sans: [...fontFamily.sans],
drawn: [
'Virgil',
'ui-sans-serif',
'system-ui',
'sans-serif',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji'
]
},
animation: {
'border-beam': 'border-beam calc(var(--duration)*1s) infinite linear',
meteor: 'meteor 5s linear infinite',
shimmer: 'shimmer 8s infinite',
'spin-around': 'spin-around calc(var(--speed) * 2) infinite linear',
magicslide: 'magicslide var(--speed) ease-in-out infinite alternate'
},
keyframes: {
'border-beam': {
'100%': {
'offset-distance': '100%'
}
},
meteor: {
'0%': { transform: 'rotate(215deg) translateX(0)', opacity: '1' },
'70%': { opacity: '1' },
'100%': {
transform: 'rotate(215deg) translateX(-500px)',
opacity: '0'
}
},
shimmer: {
'0%, 90%, 100%': {
'background-position': 'calc(-100% - var(--shimmer-width)) 0'
},
'30%, 60%': {
'background-position': 'calc(100% + var(--shimmer-width)) 0'
}
},
'spin-around': {
'0%': {
transform: 'translateZ(0) rotate(0)'
},
'15%, 35%': {
transform: 'translateZ(0) rotate(90deg)'
},
'65%, 85%': {
transform: 'translateZ(0) rotate(270deg)'
},
'100%': {
transform: 'translateZ(0) rotate(360deg)'
}
},
magicslide: {
to: {
transform: 'translate(calc(100cqw - 100%), 0)'
}
}
}
}
}
};
export default config;
+4
View File
@@ -20,6 +20,10 @@ PUBLIC_IS_SELFHOSTED=false
# CSP_FONT_SRC_DOMAINS= # CSP_FONT_SRC_DOMAINS=
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src) # CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
# Google Drive video picker (lesson editor). OAuth Web client ID + Browser API key from Google Cloud Console (Picker API + Drive API enabled).
# PUBLIC_GOOGLE_PICKER_CLIENT_ID=
# PUBLIC_GOOGLE_PICKER_API_KEY=
# Unsplash setup - Optional if you want to use Unsplash images # Unsplash setup - Optional if you want to use Unsplash images
UNSPLASH_API_KEY= UNSPLASH_API_KEY=
+1 -1
View File
@@ -16,7 +16,7 @@
"test": "jest", "test": "jest",
"test:watch": "pnpm run test --watch", "test:watch": "pnpm run test --watch",
"ci": "cp .env.example .env && vite build && vite preview", "ci": "cp .env.example .env && vite build && vite preview",
"script:translate": "node ./scripts/translate.cjs" "translate": "node ./scripts/translate.cjs"
}, },
"dependencies": { "dependencies": {
"@better-auth/sso": "1.4.18", "@better-auth/sso": "1.4.18",
+21 -1
View File
@@ -9,6 +9,7 @@ const jessy = require('jessy');
const set = require('lodash/set'); const set = require('lodash/set');
const englishTranslations = require('../src/lib/utils/translations/en.json'); const englishTranslations = require('../src/lib/utils/translations/en.json');
let prettierPromise;
// Load env variables // Load env variables
dotenv.config(); dotenv.config();
@@ -38,6 +39,25 @@ const selectedLanguageFiles = requestedLanguages.length
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const getPrettier = async () => {
if (!prettierPromise) {
prettierPromise = import('prettier');
}
return prettierPromise;
};
const writeFormattedTranslationFile = async (outputFilePath, translations) => {
const prettier = await getPrettier();
const prettierConfig = (await prettier.resolveConfig(outputFilePath)) ?? {};
const formattedJson = await prettier.format(JSON.stringify(translations, null, 2), {
...prettierConfig,
filepath: outputFilePath
});
fs.writeFileSync(outputFilePath, formattedJson);
};
const getMissingTranslations = async (toLanguage, outputFilePath) => { const getMissingTranslations = async (toLanguage, outputFilePath) => {
const englishKeys = keys(englishTranslations); const englishKeys = keys(englishTranslations);
const targetTranslations = require(outputFilePath); // Load target language translations const targetTranslations = require(outputFilePath); // Load target language translations
@@ -109,7 +129,7 @@ const translateLanguage = async (fromLanguage, toLanguage, outputFilePath) => {
set(targetTranslations, key, value); set(targetTranslations, key, value);
} }
fs.writeFileSync(outputFilePath, JSON.stringify(targetTranslations, null, 2)); await writeFormattedTranslationFile(outputFilePath, targetTranslations);
console.log(`${toLanguage.toUpperCase()} translations updated successfully.`); console.log(`${toLanguage.toUpperCase()} translations updated successfully.`);
} else { } else {
console.log(`Failed to update ${toLanguage.toUpperCase()} translations.`); console.log(`Failed to update ${toLanguage.toUpperCase()} translations.`);
+2
View File
@@ -4,6 +4,8 @@
@import '@cio/ui/output.css'; @import '@cio/ui/output.css';
@source '../../packages/ui/src/custom/animation/**/*.svelte';
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/* Geist variable font — self-hosted */ /* Geist variable font — self-hosted */
@@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import * as Select from '@cio/ui/base/select';
import { Search } from '@cio/ui/custom/search'; import { Search } from '@cio/ui/custom/search';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { SortPopover } from '$features/ui';
import type { OrganizationAudienceSortBy, OrganizationAudienceSortOrder } from '$features/org/utils/types';
interface Props { interface Props {
hasSelection: boolean; hasSelection: boolean;
selectedCount: number; selectedCount: number;
searchValue?: string; searchValue?: string;
sortValue: string; sortBy: OrganizationAudienceSortBy;
onSortChange: (value: string) => void; sortOrder: OrganizationAudienceSortOrder;
onSortChange: (sortBy: OrganizationAudienceSortBy, sortOrder: OrganizationAudienceSortOrder) => void;
onOpenAssign: () => void; onOpenAssign: () => void;
} }
@@ -18,19 +20,37 @@
hasSelection, hasSelection,
selectedCount, selectedCount,
searchValue = $bindable(''), searchValue = $bindable(''),
sortValue, sortBy,
sortOrder,
onSortChange, onSortChange,
onOpenAssign onOpenAssign
}: Props = $props(); }: Props = $props();
const sortOptions = [ const sortOptions = [
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' }, { label: t.get('audience.date_joined'), value: 'createdAt' },
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' }, { label: t.get('audience.name'), value: 'name' },
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' }, { label: t.get('audience.email'), value: 'email' }
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
]; ];
let localSortKey = $state(sortBy);
let localSortOrder = $state(sortOrder);
$effect(() => {
localSortKey = sortBy;
localSortOrder = sortOrder;
});
function handleSortKeyChange(key: string) {
onSortChange(key as OrganizationAudienceSortBy, localSortOrder);
}
function handleOrderChange(order: 'asc' | 'desc') {
onSortChange(localSortKey, order);
}
function handleClearSort() {
onSortChange('createdAt', 'desc');
}
</script> </script>
<Page.BodyHeader> <Page.BodyHeader>
@@ -46,20 +66,16 @@
{:else} {:else}
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" /> <Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort"> <SortPopover
<Select.Trigger class="w-full md:w-[220px]"> {sortOptions}
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')} bind:sortKey={localSortKey}
</Select.Trigger> bind:selectedOrder={localSortOrder}
<Select.Content> defaultSortKey="createdAt"
<Select.Group> defaultSortOrder="desc"
{#each sortOptions as option (option.value)} onSortKeyChange={handleSortKeyChange}
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}> onOrderChange={handleOrderChange}
{option.label} onClearFilters={handleClearSort}
</Select.Item> />
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div> </div>
{/if} {/if}
</Page.BodyHeader> </Page.BodyHeader>
@@ -53,7 +53,6 @@
let deleteCandidate = $state<OrganizationAudienceMember | null>(null); let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
let deleteDialogOpen = $state(false); let deleteDialogOpen = $state(false);
let searchValue = $state(query.search ?? ''); let searchValue = $state(query.search ?? '');
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
async function handleResendInvite(email: string) { async function handleResendInvite(email: string) {
inviteActionEmail = email; inviteActionEmail = email;
@@ -126,7 +125,6 @@
$effect(() => { $effect(() => {
searchValue = query.search ?? ''; searchValue = query.search ?? '';
sortValue = `${query.sortBy}:${query.sortOrder}`;
}); });
$effect(() => { $effect(() => {
@@ -179,23 +177,11 @@
}); });
} }
function handleSortChange(nextSortValue: string) { function handleSortChange(
if (nextSortValue === sortValue) { sortBy: OrganizationAudienceQuery['sortBy'],
return; sortOrder: OrganizationAudienceQuery['sortOrder']
} ) {
void navigateAudience({ ...query, page: 1, sortBy, sortOrder });
sortValue = nextSortValue;
const [sortBy, sortOrder] = nextSortValue.split(':') as [
OrganizationAudienceQuery['sortBy'],
OrganizationAudienceQuery['sortOrder']
];
void navigateAudience({
...query,
page: 1,
sortBy,
sortOrder
});
} }
function openDeleteConfirmation(member: OrganizationAudienceMember) { function openDeleteConfirmation(member: OrganizationAudienceMember) {
@@ -236,7 +222,8 @@
{hasSelection} {hasSelection}
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
bind:searchValue bind:searchValue
{sortValue} sortBy={query.sortBy}
sortOrder={query.sortOrder}
onSortChange={handleSortChange} onSortChange={handleSortChange}
onOpenAssign={() => (assignModalOpen = true)} onOpenAssign={() => (assignModalOpen = true)}
/> />
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { Button } from '@cio/ui/base/button';
import { MultiSelectList } from '@cio/ui'; import { MultiSelectList } from '@cio/ui';
import { TextareaField } from '@cio/ui/custom/textarea-field'; import { TextareaField } from '@cio/ui/custom/textarea-field';
import * as RadioGroup from '@cio/ui/base/radio-group'; import * as RadioGroup from '@cio/ui/base/radio-group';
@@ -17,15 +16,23 @@
title: string; title: string;
} }
interface Props { interface Program {
courses: Course[]; id: string;
name: string;
} }
let { courses }: Props = $props(); interface Props {
courses: Course[];
programs: Program[];
}
let { courses, programs }: Props = $props();
let recipientCsv = $state(''); let recipientCsv = $state('');
let courseAccessMode = $state('none'); let courseAccessMode = $state('none');
let programAccessMode = $state('none');
let selectedCourseIds = new SvelteSet<string>(); let selectedCourseIds = new SvelteSet<string>();
let selectedProgramIds = new SvelteSet<string>();
let sendEmail = $state(true); let sendEmail = $state(true);
let isSubmitting = $state(false); let isSubmitting = $state(false);
@@ -37,9 +44,19 @@
} }
} }
function toggleProgram(programId: string) {
if (selectedProgramIds.has(programId)) {
selectedProgramIds.delete(programId);
} else {
selectedProgramIds.add(programId);
}
}
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, '')); const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
async function handleSubmit() { async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!recipientCsv.trim()) { if (!recipientCsv.trim()) {
snackbar.error('audience.import.snackbar_no_emails'); snackbar.error('audience.import.snackbar_no_emails');
return; return;
@@ -51,7 +68,9 @@
const result = await orgApi.importAudienceMembers({ const result = await orgApi.importAudienceMembers({
recipientCsv, recipientCsv,
allCourses: courseAccessMode === 'all', allCourses: courseAccessMode === 'all',
allPrograms: programAccessMode === 'all',
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined, courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
sendEmail sendEmail
}); });
@@ -66,7 +85,7 @@
} }
</script> </script>
<div class="space-y-6"> <form id="import-audience-form" class="space-y-6" onsubmit={handleSubmit}>
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p> <p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
<TextareaField <TextareaField
@@ -108,11 +127,38 @@
{/if} {/if}
</div> </div>
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> --> {#if programs.length > 0}
<div class="space-y-3">
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
<div class="flex items-center gap-2">
<RadioGroup.Item value="none" id="program-none" />
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="all" id="program-all" />
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="select" id="program-select" />
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
</div>
</RadioGroup.Root>
<div class="flex gap-3"> {#if programAccessMode === 'select'}
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}> <MultiSelectList
{$t('audience.import.submit')} class="ml-6"
</Button> listClass="max-h-40"
</div> heading={$t('audience.import.select_programs')}
</div> emptyMessage={$t('audience.import.select_programs_placeholder')}
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
isSelected={(id) => selectedProgramIds.has(id)}
onToggle={toggleProgram}
namePrefix="import-program"
/>
{/if}
</div>
{/if}
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
</form>
@@ -8,9 +8,7 @@
import { Progress } from '@cio/ui/base/progress'; import { Progress } from '@cio/ui/base/progress';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui'; import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
import { BackButton } from '@cio/ui';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import type { UserAnalytics } from '$lib/utils/types/analytics'; import type { UserAnalytics } from '$lib/utils/types/analytics';
let { data } = $props(); let { data } = $props();
@@ -60,16 +58,12 @@
</script> </script>
{#if userAnalytics} {#if userAnalytics}
<div class="p-5">
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
</div>
<div class="px-5 py-1"> <div class="px-5 py-1">
<HeroProfileCard user={userAnalytics!.user} /> <HeroProfileCard user={userAnalytics!.user} />
<div class="mt-5 px-0"> <div class="mt-5 px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each userMetrics as activity} {#each userMetrics as activity (activity.title)}
<ActivityCard {activity} /> <ActivityCard {activity} />
{/each} {/each}
</div> </div>
@@ -114,54 +108,52 @@
</div> </div>
{#each filteredCourses as course (course.id)} {#each filteredCourses as course (course.id)}
{#key index} <div
<div class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${ course.lessons_count === course.lessons_completed
course.lessons_count === course.lessons_completed ? 'border-green-200 bg-green-50 dark:bg-green-100'
? 'border-green-200 bg-green-50 dark:bg-green-100' : 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100' }`}
}`} transition:fade={{ duration: 300 }}
transition:fade={{ duration: 300 }} >
> <div class="flex items-center justify-between gap-4">
<div class="flex items-center justify-between gap-4"> <div class="flex w-4/5 items-center gap-4">
<div class="flex w-4/5 items-center gap-4"> <img
<img src={course.logo || '/images/classroomio-course-img-template.jpg'}
src={course.logo || '/images/classroomio-course-img-template.jpg'} alt={course.title}
alt={course.title} class="h-20 w-24 rounded-md"
class="h-20 w-24 rounded-md" />
/> <div class="mb-4 gap-4">
<div class="mb-4 gap-4"> <a href={`/courses/${course.id}`}>
<a href={`/courses/${course.id}`}> <p class="text-lg font-semibold text-gray-600">
<p class="text-lg font-semibold text-gray-600"> {course.title}
{course.title}
</p>
</a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
</p> </p>
</div> </a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
</p>
</div> </div>
<Badge
class={`${
course.lessons_count === course.lessons_completed
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div> </div>
<div class="flex w-full items-center gap-1"> <Badge
<Progress value={course.progress_percentage} /> class={`${
<p>{course.progress_percentage}%</p> course.lessons_count === course.lessons_completed
</div> ? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div> </div>
{/key}
<div class="flex w-full items-center gap-1">
<Progress value={course.progress_percentage} />
<p>{course.progress_percentage}%</p>
</div>
</div>
{/each} {/each}
</div> </div>
</div> </div>
@@ -85,7 +85,8 @@ class AutomationApi extends BaseApiWithErrors {
onSuccess: (response) => { onSuccess: (response) => {
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key)); this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
if (this.usage) { if (this.usage) {
const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length; const revokedKeyType = response.data.type;
const activeKeys = this.keys.filter((key) => key.type === revokedKeyType && !key.revokedAt).length;
this.usage = { ...this.usage, activeKeys }; this.usage = { ...this.usage, activeKeys };
} }
snackbar.success('snackbar.automation.revoked'); snackbar.success('snackbar.automation.revoked');
@@ -1,14 +1,221 @@
<script lang="ts"> <script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import { getMaskedAutomationSecret } from '$features/automation/utils/automation-utils';
import { isOrgAdmin, isEnterprisePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import * as Alert from '@cio/ui/base/alert'; import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import * as Code from '@cio/ui/custom/code';
import { Empty } from '@cio/ui/custom/empty';
import * as Dialog from '@cio/ui/base/dialog';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Field from '@cio/ui/base/field'; import * as Field from '@cio/ui/base/field';
import * as Table from '@cio/ui/base/table';
import { InputField } from '@cio/ui/custom/input-field';
import { IconButton } from '@cio/ui/custom/icon-button';
import { UpgradeBanner } from '$features/ui';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import KeyIcon from '@lucide/svelte/icons/key';
import PlusIcon from '@lucide/svelte/icons/plus';
let generatedSecret = $state<string | null>(null);
let isCreateKeyModalOpen = $state(false);
let keyLabel = $state('');
const canCreateKey = $derived(keyLabel.trim().length > 0 && !automationApi.isLoading);
function resetCreateKeyModal() {
keyLabel = '';
automationApi.resetErrors();
}
function openCreateKeyModal() {
if (!$isEnterprisePlan) {
snackbar.error('upgrade.required');
return;
}
resetCreateKeyModal();
isCreateKeyModalOpen = true;
}
async function onGenerateKey() {
if (!$isEnterprisePlan) return;
const result = await automationApi.createKey('api', keyLabel);
if (!result) return;
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
isCreateKeyModalOpen = false;
resetCreateKeyModal();
}
async function onRevokeKey(keyId: string) {
if (!$isEnterprisePlan) return;
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
}
async function onRotateKey(keyId: string) {
if (!$isEnterprisePlan) return;
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
const apiKeys = $derived(automationApi.keys.filter((key) => key.type === 'api'));
</script> </script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2"> <Field.Group class="mx-auto w-full space-y-2">
<Alert.Callout <UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
variant="information"
title={$t('automation.tabs.api')} {#if !$isOrgAdmin}
description={$t('automation.coming_soon')} <Alert.Callout
class="w-full" variant="information"
/> title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-medium">
<KeyIcon class="size-5" />
{$t('automation.api.keys.title')}
</div>
{#if $isOrgAdmin}
<IconButton
onclick={openCreateKeyModal}
disabled={automationApi.isLoading || !$isEnterprisePlan}
tooltip={$t('automation.api.keys.generate')}
>
<PlusIcon size={16} />
</IconButton>
{/if}
</div>
<p class="ui:text-muted-foreground text-sm">{$t('automation.api.keys.description')}</p>
{#if generatedSecret}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.api.keys.secret_once')}</p>
<div class="mt-3">
<Code.Root code={generatedSecret} lang="bash" hideLines={true} class="ui:break-all ui:text-xs">
<Code.CopyButton />
</Code.Root>
</div>
</div>
{/if}
{#if $isOrgAdmin}
{#if apiKeys.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('automation.keys.table_key')}</Table.Head>
<Table.Head>{$t('automation.keys.table_status')}</Table.Head>
<Table.Head>{$t('automation.keys.table_created')}</Table.Head>
<Table.Head>{$t('automation.keys.table_last_used')}</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each apiKeys as key (key.id)}
<Table.Row>
<Table.Cell>
<div>
<p class="font-medium">{key.label}</p>
<p class="ui:text-muted-foreground text-xs">
{getMaskedAutomationSecret(key.secretPrefix)}
</p>
</div>
</Table.Cell>
<Table.Cell>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{new Date(key.createdAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')}
</Table.Cell>
<Table.Cell>
{#if !key.revokedAt}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton aria-label="actions">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onRotateKey(key.id)}>
{$t('automation.keys.rotate')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onRevokeKey(key.id)} class="ui:text-destructive">
{$t('automation.keys.revoke')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<Empty
title={$t('automation.api.keys.empty_title')}
description={$t('automation.api.keys.empty_description')}
icon={KeyIcon}
variant="page"
>
{#if $isOrgAdmin}
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading || !$isEnterprisePlan}>
<PlusIcon size={16} />
{$t('automation.api.keys.generate')}
</Button>
{/if}
</Empty>
{/if}
{/if}
</div>
</Field.Group> </Field.Group>
<Dialog.Root
bind:open={isCreateKeyModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetCreateKeyModal();
}
}}
>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('automation.api.keys.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('automation.api.keys.modal.description')}</Dialog.Description>
</Dialog.Header>
<div class="py-2">
<InputField
label={$t('automation.api.keys.modal.name_label')}
placeholder={$t('automation.api.keys.modal.name_placeholder')}
bind:value={keyLabel}
errorMessage={automationApi.errors.label}
autoFocus
/>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (isCreateKeyModalOpen = false)}>
{$t('automation.api.keys.modal.cancel')}
</Button>
<Button onclick={onGenerateKey} loading={automationApi.isLoading} disabled={!canCreateKey}>
{$t('automation.api.keys.modal.save')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -13,6 +13,7 @@
import { Badge } from '@cio/ui/base/badge'; import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import * as Code from '@cio/ui/custom/code'; import * as Code from '@cio/ui/custom/code';
import { Empty } from '@cio/ui/custom/empty';
import * as Dialog from '@cio/ui/base/dialog'; import * as Dialog from '@cio/ui/base/dialog';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Field from '@cio/ui/base/field'; import * as Field from '@cio/ui/base/field';
@@ -97,6 +98,7 @@
}); });
const isLastUsageCard = (index: number) => index === usageCards.length - 1; const isLastUsageCard = (index: number) => index === usageCards.length - 1;
const hasActiveMcpKey = $derived(mcpKeys.some((key) => !key.revokedAt));
</script> </script>
<Field.Group class="mx-auto w-full space-y-2"> <Field.Group class="mx-auto w-full space-y-2">
@@ -209,64 +211,73 @@
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
{:else} {:else}
<Alert.Callout <Empty
variant="information"
title={$t('automation.keys.empty_title')} title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')} description={$t('automation.keys.empty_description')}
class="w-full" icon={KeyIcon}
/> variant="page"
>
{#if $isOrgAdmin}
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading}>
<PlusIcon size={16} />
{$t('automation.mcp.keys.generate')}
</Button>
{/if}
</Empty>
{/if} {/if}
{/if} {/if}
</div> </div>
<Field.Set class="gap-3!"> {#if hasActiveMcpKey}
<Field.Legend class="flex items-center gap-2"> <Field.Set class="gap-3!">
<SparklesIcon class="size-5" /> <Field.Legend class="flex items-center gap-2">
{$t('automation.mcp.setup.title')} <SparklesIcon class="size-5" />
</Field.Legend> {$t('automation.mcp.setup.title')}
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description> </Field.Legend>
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
<Tabs.Root bind:value={activeSetupTab} class="w-full"> <Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto"> <Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger> <Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger> <Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger> <Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
<Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger> <Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="claude-code" class="mt-4"> <Tabs.Content value="claude-code" class="mt-4">
<Code.Overflow> <Code.Overflow>
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash"> <Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
<Code.CopyButton /> <Code.CopyButton />
</Code.Root> </Code.Root>
</Code.Overflow> </Code.Overflow>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="codex" class="mt-4"> <Tabs.Content value="codex" class="mt-4">
<Code.Overflow> <Code.Overflow>
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash"> <Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
<Code.CopyButton /> <Code.CopyButton />
</Code.Root> </Code.Root>
</Code.Overflow> </Code.Overflow>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="cursor" class="mt-4"> <Tabs.Content value="cursor" class="mt-4">
<Code.Overflow> <Code.Overflow>
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json"> <Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
<Code.CopyButton /> <Code.CopyButton />
</Code.Root> </Code.Root>
</Code.Overflow> </Code.Overflow>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="opencode" class="mt-4"> <Tabs.Content value="opencode" class="mt-4">
<Code.Overflow> <Code.Overflow>
<Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json"> <Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json">
<Code.CopyButton /> <Code.CopyButton />
</Code.Root> </Code.Root>
</Code.Overflow> </Code.Overflow>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
</Field.Set> </Field.Set>
{/if}
</Field.Group> </Field.Group>
<Dialog.Root <Dialog.Root
@@ -9,14 +9,14 @@ import type {
UpvotePostRequest UpvotePostRequest
} from '../utils/types'; } from '../utils/types';
import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community'; import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community';
import { currentOrg as currentOrgStore } from '$lib/utils/store/org'; import { basePath, isStudentExperience } from '$lib/utils/store/app';
import type { TCreateCommunityQuestion } from '@cio/utils/validation/community'; import type { TCreateCommunityQuestion } from '@cio/utils/validation/community';
import { coursesApi } from '$features/course/api/courses.svelte'; import { coursesApi } from '$features/course/api/courses.svelte';
import { currentCommunityQuestion } from '../utils/store'; import { currentCommunityQuestion } from '../utils/store';
import { currentOrg as currentOrgStore } from '$lib/utils/store/org';
import { generateSlug } from '@cio/utils/functions'; import { generateSlug } from '@cio/utils/functions';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { basePath, isStudentExperience } from '$lib/utils/store/app';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile as profileStore } from '$lib/utils/store/user'; import { profile as profileStore } from '$lib/utils/store/user';
@@ -145,6 +145,7 @@
/> />
<Page.Title>{communityApi.question.title}</Page.Title> <Page.Title>{communityApi.question.title}</Page.Title>
</div> </div>
<Page.Subtitle>{$t('community.question.page_subtitle')}</Page.Subtitle>
{/if} {/if}
</Page.HeaderContent> </Page.HeaderContent>
{#if communityApi.question.authorId === $profile.id} {#if communityApi.question.authorId === $profile.id}
@@ -36,10 +36,12 @@
} }
}); });
let isCoursesFetched = $state(false);
$effect(() => { $effect(() => {
if (!$profile.id || !$currentOrg.id) return; if (!$profile.id || !$currentOrg.id || isCoursesFetched) return;
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id); communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
isCoursesFetched = true;
}); });
let filteredQuestions = $derived( let filteredQuestions = $derived(
@@ -1,9 +1,12 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { import type {
GetOrgCoursesRequest, GetOrgCoursesRequest,
GetOrgCoursesRequestQuery,
GetRecommendedCoursesRequest, GetRecommendedCoursesRequest,
GetUserEnrolledCoursesRequest, GetUserEnrolledCoursesRequest,
OrgCourses, OrgCourses,
OrgCoursesPagination,
OrgCoursesQuery,
RecommendedCourses, RecommendedCourses,
UserEnrolledCourses UserEnrolledCourses
} from '$features/course/types'; } from '$features/course/types';
@@ -15,8 +18,15 @@ import { SvelteSet } from 'svelte/reactivity';
*/ */
export class CoursesApi extends BaseApiWithErrors { export class CoursesApi extends BaseApiWithErrors {
orgCourses = $state<OrgCourses>([]); orgCourses = $state<OrgCourses>([]);
orgCoursesPagination = $state<OrgCoursesPagination | null>(null);
enrolledCourses = $state<UserEnrolledCourses>([]); enrolledCourses = $state<UserEnrolledCourses>([]);
recommendedCourses = $state<RecommendedCourses>([]); recommendedCourses = $state<RecommendedCourses>([]);
private activeOrgCoursesRequestController: AbortController | null = null;
cancelOrgCoursesRequest() {
this.activeOrgCoursesRequestController?.abort();
this.activeOrgCoursesRequestController = null;
}
/** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */ /** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */
removeCourseFromLists(courseId: string) { removeCourseFromLists(courseId: string) {
@@ -30,26 +40,79 @@ export class CoursesApi extends BaseApiWithErrors {
* Org ID is automatically added from currentOrg store * Org ID is automatically added from currentOrg store
*/ */
async getOrgCourses(tagSlugs: string[] = []) { async getOrgCourses(tagSlugs: string[] = []) {
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean))); const allCourses: OrgCourses = [];
let page = 1;
let totalPages = 1;
let lastResponse: Awaited<ReturnType<CoursesApi['getOrgCoursesPage']>> | undefined;
return this.execute<GetOrgCoursesRequest>({ while (page <= totalPages) {
const response = await this.getOrgCoursesPage({ page, limit: 100 }, tagSlugs);
if (!response) {
return response;
}
allCourses.push(...(response.data ?? []));
totalPages = response.pagination?.totalPages ?? 1;
lastResponse = response;
page += 1;
}
this.orgCourses = allCourses;
this.orgCoursesPagination = lastResponse?.pagination ?? null;
return lastResponse;
}
async getOrgCoursesPage(
query: Partial<OrgCoursesQuery> = {},
tagSlugs: string[] = [],
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
) {
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
const requestQuery: GetOrgCoursesRequestQuery = {
page: String(query.page ?? 1),
limit: String(query.limit ?? 20),
search: query.search,
tags: normalizedTagSlugs.length > 0 ? normalizedTagSlugs.join(',') : undefined
};
let requestSignal = options.signal;
let requestController: AbortController | null = null;
if (options.abortPrevious) {
this.cancelOrgCoursesRequest();
requestController = new AbortController();
this.activeOrgCoursesRequestController = requestController;
requestSignal = requestController.signal;
}
const response = await this.execute<GetOrgCoursesRequest>({
requestFn: () => requestFn: () =>
classroomio.organization.courses.$get( classroomio.organization.courses.$get(
normalizedTagSlugs.length > 0 {
? { query: requestQuery
query: { },
tags: normalizedTagSlugs.join(',') {
} init: {
} signal: requestSignal
: { query: {} } }
}
), ),
logContext: 'fetching org courses', logContext: 'fetching org courses',
onSuccess: (response) => { onSuccess: (response) => {
if (response.data) { if (response.data) {
this.orgCourses = response.data; this.orgCourses = response.data;
} }
this.orgCoursesPagination = response.pagination;
} }
}); });
if (requestController && this.activeOrgCoursesRequestController === requestController) {
this.activeOrgCoursesRequestController = null;
}
return response;
} }
/** /**
@@ -180,21 +180,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
onSuccess: (response) => { onSuccess: (response) => {
if (response.data) { if (response.data) {
snackbar.success('Post updated successfully'); snackbar.success('Post updated successfully');
// Update local state, preserving author fields from existing feed this.feeds = this.feeds.map((feed) => {
const index = this.feeds.findIndex((f) => f.id === feedId); if (feed.id !== feedId) {
if (index !== -1) { return feed;
const existingFeed = this.feeds[index]; }
// Merge response data with existing feed to preserve author fields
this.feeds[index] = { return {
...existingFeed, ...feed,
...response.data, ...response.data,
// Preserve author fields from existing feed authorProfileId: feed.authorProfileId,
authorProfileId: existingFeed.authorProfileId, authorFullname: feed.authorFullname,
authorFullname: existingFeed.authorFullname, authorUsername: feed.authorUsername,
authorUsername: existingFeed.authorUsername, authorAvatarUrl: feed.authorAvatarUrl
authorAvatarUrl: existingFeed.authorAvatarUrl
}; };
} });
} }
}, },
onError: (result) => { onError: (result) => {
@@ -232,21 +231,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
logContext: 'updating newsfeed reaction', logContext: 'updating newsfeed reaction',
onSuccess: (response) => { onSuccess: (response) => {
if (response.data) { if (response.data) {
// Update local state, preserving author fields from existing feed this.feeds = this.feeds.map((feed) => {
const index = this.feeds.findIndex((f) => f.id === feedId); if (feed.id !== feedId) {
if (index !== -1) { return feed;
const existingFeed = this.feeds[index]; }
// Merge response data with existing feed to preserve author fields
this.feeds[index] = { return {
...existingFeed, ...feed,
...response.data, ...response.data,
// Preserve author fields from existing feed authorProfileId: feed.authorProfileId,
authorProfileId: existingFeed.authorProfileId, authorFullname: feed.authorFullname,
authorFullname: existingFeed.authorFullname, authorUsername: feed.authorUsername,
authorUsername: existingFeed.authorUsername, authorAvatarUrl: feed.authorAvatarUrl
authorAvatarUrl: existingFeed.authorAvatarUrl
}; };
} });
} }
}, },
onError: (result) => { onError: (result) => {
@@ -1,15 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Component } from 'svelte'; import type { Component, Snippet } from 'svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { Badge } from '@cio/ui/base/badge'; import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
import UserIcon from '@lucide/svelte/icons/user'; import UserIcon from '@lucide/svelte/icons/user';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'; import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'; import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'; import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { Separator } from '@cio/ui/base/separator';
import * as Item from '@cio/ui/base/item';
import { Progress } from '@cio/ui/base/progress'; import { Progress } from '@cio/ui/base/progress';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
@@ -30,9 +28,11 @@
isOnLandingPage?: boolean; isOnLandingPage?: boolean;
isLMS?: boolean; isLMS?: boolean;
isExplore?: boolean; isExplore?: boolean;
href?: string;
actions?: Snippet;
} }
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props(); let { course, isOnLandingPage, isLMS, isExplore, href, actions }: Props = $props();
let { let {
bannerImage, bannerImage,
@@ -42,6 +42,7 @@
description = '', description = '',
isPublished = false, isPublished = false,
totalLessons = 0, totalLessons = 0,
totalExercises = 0,
totalStudents = 0, totalStudents = 0,
currency = 'USD', currency = 'USD',
progressRate = 45, progressRate = 45,
@@ -54,7 +55,7 @@
} = $derived({ } = $derived({
id: course.id, id: course.id,
slug: course.slug, slug: course.slug,
bannerImage: course.logo || '/images/classroomio-course-img-template.jpg', bannerImage: course.logo || DEFAULT_COURSE_BANNER_IMAGE,
title: course.title, title: course.title,
type: course.type, type: course.type,
description: course.description, description: course.description,
@@ -66,6 +67,10 @@
}, },
currency: course.currency, currency: course.currency,
totalLessons: course.lessonCount, totalLessons: course.lessonCount,
totalExercises: (() => {
const c = course as { exerciseCount?: number };
return typeof c.exerciseCount === 'number' ? c.exerciseCount : 0;
})(),
progressRate: (() => { progressRate: (() => {
const c = course as { exerciseCount?: number; exercisesCompleted?: number }; const c = course as { exerciseCount?: number; exercisesCompleted?: number };
if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') { if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') {
@@ -122,91 +127,104 @@
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount)); let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
let courseUrl = $derived( let courseUrl = $derived(
isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}` href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
);
const typeBadge = $derived(
type && COURSE_TAG[type]
? {
label: COURSE_TAG[type].label,
icon: COURSE_TAG[type].icon,
iconClass: COURSE_TAG[type].iconStyle
}
: undefined
); );
</script> </script>
<Item.Root variant="outline" class="group relative max-w-[320px] p-3!"> <CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
{#snippet child({ props })} {#snippet media()}
<a href={resolve(courseUrl, {})} {...props}> <Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
{#if !isLMS && !isOnLandingPage} {/snippet}
<CardDropdown {id} {title} {description} />
{/if}
<Item.Header> {#snippet overlay()}
<Item.Media variant="image" class="relative h-50! w-full!"> {#if actions}
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" /> {@render actions()}
{:else if !isLMS && !isOnLandingPage}
<CardDropdown {id} {title} {description} />
{/if}
{/snippet}
{#if type} {#snippet tags()}
{@const tag = COURSE_TAG[type]} {#if !isLMS}
<Badge class="absolute bottom-2 left-2 z-10 flex items-center rounded-md! capitalize" variant="secondary"> <CourseTagsOverflow tags={courseTags} variant="card" />
<tag.icon class={tag.iconStyle} /> {/if}
{tag.label} {/snippet}
</Badge>
{/if}
</Item.Media>
</Item.Header>
<Item.Content> {#snippet footer()}
<Item.Title class="line-clamp-2 min-h-14 text-base!"> <div class="flex justify-between {isLMS && 'items-center'} w-full">
{title} <div class="w-[60%]">
</Item.Title> {#if isLMS || isOnLandingPage}
<p class="text-xs {!isLMS && 'pl-2'} flex gap-1 dark:text-white">
<Item.Description class="min-h-[63px]">{description}</Item.Description> <span>
{#if !isLMS}
<CourseTagsOverflow tags={courseTags} variant="card" />
{/if}
<Separator class="my-3" />
<div class="flex justify-between {isLMS && 'items-center'} w-full">
<div class="w-[50%]">
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
{totalLessons} {totalLessons}
{$t('courses.course_card.lessons_number')} {$t('courses.course_card.lessons_number')}
</p> </span>
<div class="py-2 text-xs"> &
{#if isOnLandingPage} <span>
<span class="px-2"> {pluralize($t('courses.course_card.exercise'), totalExercises, true)}
{#if !cost} </span>
{$t('course.navItem.landing_page.pricing_section.free')} </p>
{:else if pricingData.showDiscount} {/if}
{formatter.format(cost)} <div class="py-2 text-xs">
<span class="line-through"> {#if isOnLandingPage}
{formatter?.format(pricingData?.cost ?? 0)} <span class="px-2">
</span> {#if !cost}
{:else} {$t('course.navItem.landing_page.pricing_section.free')}
{formatter.format(cost)} {:else if pricingData.showDiscount}
{/if} {formatter.format(cost)}
<span class="line-through">
{formatter?.format(pricingData?.cost ?? 0)}
</span> </span>
{:else if isLMS}
{#if !isExplore}
<div class="flex w-3/4 items-center gap-2">
<Progress value={progressRate} />
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
</div>
{/if}
{:else} {:else}
<CoursePublishBadge {isPublished} /> {formatter.format(cost)}
{/if} {/if}
</div> </span>
</div> {:else if isLMS}
{#if !isExplore}
{#if isLMS} <div class="flex w-3/4 items-center gap-2">
<Button variant="outline"> <Progress value={progressRate} />
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')} <p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
</div>
<ArrowRightIcon class="custom" /> {/if}
</Button> {:else}
{:else if !isOnLandingPage} <CoursePublishBadge {isPublished} />
<div class="flex flex-col justify-between">
<p class="pl-2 text-xs dark:text-white">
{pluralize($t('courses.course_card.students'), totalStudents, true)}
</p>
</div>
{/if} {/if}
</div> </div>
</Item.Content> </div>
</a>
{#if isLMS}
<Button variant="outline">
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
<ArrowRightIcon class="custom" />
</Button>
{:else if !isOnLandingPage}
<div class="flex flex-col justify-end gap-1 text-right">
<p class="pl-2 text-xs whitespace-nowrap dark:text-white">
<span>
{totalLessons}
{$t('courses.course_card.lessons_number')}
</span>
&
<span>
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
</span>
</p>
<p class="pl-2 text-xs dark:text-white">
{pluralize($t('courses.course_card.students'), totalStudents, true)}
</p>
</div>
{/if}
</div>
{/snippet} {/snippet}
</Item.Root> </CourseCard>
@@ -1,18 +1,13 @@
<script lang="ts"> <script lang="ts">
import { import {
COURSE_SORT_OPTIONS, COURSE_SORT_OPTIONS,
CourseSortOrder,
DEFAULT_COURSE_SORT, DEFAULT_COURSE_SORT,
DEFAULT_SORT_ORDER, DEFAULT_SORT_ORDER,
type CourseSortBy type CourseSortBy,
type CourseSortOrder
} from '../utils/constants'; } from '../utils/constants';
import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Checkbox } from '@cio/ui/base/checkbox'; import { Checkbox } from '@cio/ui/base/checkbox';
import { IconButton } from '@cio/ui/custom/icon-button'; import { SortPopover } from '$features/ui';
import FilterIcon from '@lucide/svelte/icons/filter';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
interface FilterTag { interface FilterTag {
@@ -54,7 +49,9 @@
onClearFilters = () => {} onClearFilters = () => {}
}: Props = $props(); }: Props = $props();
let isOpen = $state(false); const translatedSortOptions = $derived(
sortOptions.map((option) => ({ value: option.value, label: $t(option.label) }))
);
const hasActiveFilters = $derived( const hasActiveFilters = $derived(
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0 sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
@@ -65,117 +62,58 @@
} }
</script> </script>
<Popover.Root bind:open={isOpen}> <SortPopover
<Popover.Trigger> sortOptions={translatedSortOptions}
{#snippet child({ props })} bind:sortKey
<div class="relative"> bind:selectedOrder
<IconButton {...props} aria-label={$t('courses.tag_filters.filter')} tooltip={$t('courses.tag_filters.filter')}> defaultSortKey={DEFAULT_COURSE_SORT}
{#if isFiltering} defaultSortOrder={DEFAULT_SORT_ORDER}
<Spinner class="size-4" /> {isFiltering}
{:else} {hasActiveFilters}
<FilterIcon size={16} /> {onClearFilters}
{/if} >
</IconButton> {#snippet additionalContent()}
{#if hasActiveFilters} <div class="space-y-3">
<span <p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white" {#if tagGroups.length === 0}
aria-hidden="true" <p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
></span> {:else}
{/if} <div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
</div> {#each tagGroups as group (group.id)}
{/snippet} {#if group.tags.length > 0}
</Popover.Trigger> <div class="space-y-2">
<div class="space-y-1">
<Popover.Content align="end" class="w-[360px] p-3"> <p class="text-sm font-medium">{group.name}</p>
<div class="space-y-4"> {#if group.description}
<div class="flex items-center justify-between gap-2"> <p class="ui:text-muted-foreground text-xs">{group.description}</p>
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p> {/if}
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}> </div>
{$t('courses.tag_filters.clear_all')} <div class="space-y-2">
</Button> {#each group.tags as tag (tag.id)}
</div> <label
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
<div class="space-y-2"> >
<p class="ui:text-muted-foreground text-xs font-semibold uppercase"> <div class="flex items-center gap-2">
{$t('courses.tag_filters.sort')} <Checkbox
</p> checked={isTagSelected(tag.slug)}
<div class="flex flex-wrap gap-2"> onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
{#each sortOptions as option (option.value)} />
<Button <span
type="button" class="inline-block h-2.5 w-2.5 rounded-full border"
size="sm" style={`background-color: ${tag.color}`}
variant={sortKey === option.value ? 'secondary' : 'outline'} aria-hidden="true"
onclick={() => (sortKey = option.value)} ></span>
> <span class="text-sm">{tag.name}</span>
{$t(option.label)} </div>
</Button> <span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
</label>
{/each}
</div>
</div>
{/if}
{/each} {/each}
</div> </div>
</div> {/if}
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root
type="single"
variant="outline"
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
selectedOrder = value;
}
}}
>
<ToggleGroup.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
{:else}
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
{#each tagGroups as group (group.id)}
{#if group.tags.length > 0}
<div class="space-y-2">
<div class="space-y-1">
<p class="text-sm font-medium">{group.name}</p>
{#if group.description}
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
{/if}
</div>
<div class="space-y-2">
{#each group.tags as tag (tag.id)}
<label
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
>
<div class="flex items-center gap-2">
<Checkbox
checked={isTagSelected(tag.slug)}
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
/>
<span
class="inline-block h-2.5 w-2.5 rounded-full border"
style={`background-color: ${tag.color}`}
aria-hidden="true"
></span>
<span class="text-sm">{tag.name}</span>
</div>
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
</label>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div> </div>
</Popover.Content> {/snippet}
</Popover.Root> </SortPopover>
@@ -0,0 +1,261 @@
<script lang="ts">
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
import * as Avatar from '@cio/ui/base/avatar';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { ContentType } from '@cio/utils/constants/content';
import { Image } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
import CourseContentIcon from './course-content-icon.svelte';
interface Tag {
id: string;
name: string;
slug: string;
color?: string | null;
}
interface Props {
id: string;
slug?: string;
title: string;
logo?: string | null;
type?: string | null;
description?: string;
isPublished?: boolean;
lessonCount?: number;
exerciseCount?: number;
totalStudents?: number;
updatedAt?: string | null;
tags?: Tag[];
isExplore?: boolean;
isLMS?: boolean;
}
let {
id,
slug = '',
title,
logo = null,
type,
description = '',
isPublished = false,
lessonCount = 0,
exerciseCount = 0,
totalStudents = 0,
updatedAt,
tags = [],
isExplore = false,
isLMS = false
}: Props = $props();
const titleInitial = $derived(title.trim().charAt(0).toUpperCase() || 'C');
const bannerImage = $derived(logo?.trim() ? logo : '/images/classroomio-course-img-template.jpg');
const gridTemplateColumns = $derived(
isLMS
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
);
const studentPlaceholderAvatarUrls = $derived.by(() => [
buildCoursePlaceholderAvatarUrl(`${id}:student:0`),
buildCoursePlaceholderAvatarUrl(`${id}:student:1`)
]);
const emptyStateAvatarUrl = $derived(buildCoursePlaceholderAvatarUrl(`${id}:student:empty`));
const typeLabel = $derived(type ? $t(`course.navItem.settings.${type.toLowerCase()}`) : null);
const updatedLabel = $derived.by(() => {
if (!updatedAt) return null;
const d = new Date(updatedAt);
if (isNaN(d.getTime())) return null;
return `Updated ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
});
function handleRowClick() {
if (isExplore) {
goto(resolve(`/course/${slug}`, {}));
return;
}
if (isLMS) {
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
return;
}
goto(resolve(`/courses/${id}`, {}));
}
function handleOpen(e: MouseEvent) {
e.stopPropagation();
goto(resolve(`/courses/${id}`, {}));
}
function handleClone(e: MouseEvent) {
e.stopPropagation();
$copyCourseModal.open = true;
$copyCourseModal.id = id;
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
function handleDelete(e: MouseEvent) {
e.stopPropagation();
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
</script>
<ResourceListRow.Root
variant="default"
size="sm"
align="start"
class="cursor-pointer py-4!"
onclick={handleRowClick}
role="row"
>
<div class="grid w-full items-start gap-3" style="grid-template-columns: {gridTemplateColumns}">
<!-- Banner -->
<div
class="ui:border-border bg-muted relative size-28 shrink-0 overflow-hidden rounded-md border"
aria-hidden="true"
>
<Image src={bannerImage} alt="" className="h-full w-full object-cover" />
</div>
<!-- Title / type / updated -->
<div class="flex min-w-0 flex-col gap-0.5">
<p class="line-clamp-2 text-base">{title}</p>
{#if typeLabel}
<p class="ui:text-muted-foreground mt-0.5 text-sm">{typeLabel}</p>
{/if}
{#if updatedLabel}
<p class="ui:text-muted-foreground mt-0.5 text-xs">{updatedLabel}</p>
{/if}
</div>
<!-- Published badge -->
<div>
{#if isPublished}
<Badge variant="success" class="whitespace-nowrap">
{$t('courses.course_card.published')}
</Badge>
{:else}
<Badge variant="secondary" class="whitespace-nowrap">
{$t('courses.course_card.unpublished')}
</Badge>
{/if}
</div>
<!-- Tags -->
<div class="flex min-w-0 flex-wrap items-center gap-1">
{#if tags.length === 0}
<span class="ui:text-muted-foreground text-xs">—</span>
{:else}
{#each tags as tag (tag.id)}
<Badge variant="outline" class="max-w-[140px] truncate">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
{tag.name}
</Badge>
{/each}
{/if}
</div>
<!-- Lessons / exercises -->
<div class="flex flex-col gap-1 tabular-nums">
<p class="flex items-center gap-1.5 text-sm" aria-label="{lessonCount} lessons">
<CourseContentIcon type={ContentType.Lesson} size={14} />
<span class="font-medium">{lessonCount}</span>
</p>
<p class="flex items-center gap-1.5 text-sm" aria-label="{exerciseCount} exercises">
<CourseContentIcon type={ContentType.Exercise} size={14} />
<span class="font-medium">{exerciseCount}</span>
</p>
</div>
<!-- Students -->
<div class="flex items-center gap-1">
{#if totalStudents > 0}
{#if totalStudents < 3}
<div class="flex items-center gap-1">
<Avatar.Root class="ui:border-background size-6 border-2">
<Avatar.Image src={studentPlaceholderAvatarUrls[0]} alt="" loading="lazy" decoding="async" />
<Avatar.Fallback aria-hidden="true">
{titleInitial}
</Avatar.Fallback>
</Avatar.Root>
<p class="text-xs tabular-nums">{totalStudents}</p>
</div>
{:else}
<div class="flex -space-x-2">
{#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)}
<Avatar.Root class="ui:border-background size-6 border-2">
<Avatar.Image src={avatarUrl} alt="" loading="lazy" decoding="async" />
<Avatar.Fallback aria-hidden="true">
{titleInitial}
</Avatar.Fallback>
</Avatar.Root>
{/each}
<Avatar.Root class="ui:border-background size-6 border-2">
<Avatar.Fallback class="text-[10px]">+{totalStudents - 2}</Avatar.Fallback>
</Avatar.Root>
</div>
{/if}
{:else}
<Avatar.Root class="size-6">
<Avatar.Image src={emptyStateAvatarUrl} alt="" loading="lazy" decoding="async" />
<Avatar.Fallback aria-hidden="true">
{titleInitial}
</Avatar.Fallback>
</Avatar.Root>
<p class="text-xs">0</p>
{/if}
</div>
<!-- Actions -->
{#if !isLMS}
<div class="flex justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="size-8"
aria-label="Course actions"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleOpen}>
{$t('courses.course_card.context_menu.open')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleClone}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</ResourceListRow.Root>
@@ -15,7 +15,7 @@
const flipDurationMs = 300; const flipDurationMs = 300;
interface Question { interface Question {
id: number; id: number | string;
name: string; name: string;
type: number; type: number;
} }
@@ -36,23 +36,36 @@
items = e.detail.items; items = e.detail.items;
} }
function handleDndFinalize(e) { function applyItemOrder(nextItems: Array<Question>) {
items = e.detail.items; questionnaire.update((currentQuestionnaire) => {
const nextOrderById = new Map(nextItems.map((item, index) => [String(item.id), index + 1]));
const nextQuestions = currentQuestionnaire.questions.map((question) => {
const nextOrder = nextOrderById.get(String(question.id));
if (nextOrder == null) {
return question;
}
return {
...question,
isDirty: true,
order: nextOrder
};
});
const activeQuestions = nextQuestions
.filter((question) => !question.deletedAt)
.sort((leftQuestion, rightQuestion) => (leftQuestion.order ?? 0) - (rightQuestion.order ?? 0));
const deletedQuestions = nextQuestions.filter((question) => question.deletedAt);
return {
...currentQuestionnaire,
questions: [...activeQuestions, ...deletedQuestions]
};
});
} }
function handleClose() { function handleDndFinalize(e) {
items.forEach((item, index) => { items = e.detail.items;
const order = index + 1; applyItemOrder(e.detail.items);
$questionnaire.questions.some((q) => {
if (q.id === item.id) {
q.isDirty = true;
q.order = order;
return true;
}
});
});
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
$questionnaireOrder.open = false;
} }
$effect(() => { $effect(() => {
@@ -63,7 +76,9 @@
<Dialog.Root <Dialog.Root
bind:open={$questionnaireOrder.open} bind:open={$questionnaireOrder.open}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) handleClose(); if (!isOpen) {
applyItemOrder(items);
}
}} }}
> >
<Dialog.Content class="w-96"> <Dialog.Content class="w-96">
@@ -81,7 +96,7 @@
}} }}
onconsider={handleDndConsider} onconsider={handleDndConsider}
onfinalize={handleDndFinalize} onfinalize={handleDndFinalize}
class="w-full" class="max-h-[300px] w-full"
> >
{#each items as item (item.id)} {#each items as item (item.id)}
<div <div
@@ -176,6 +176,29 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'numeric.review.out_of_range': t.get( 'numeric.review.out_of_range': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range' 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
), ),
'star.edit.correct_value_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_label'
),
'star.edit.correct_value_info': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_info'
),
'star.edit.max_stars_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_label'
),
'star.edit.max_stars_info': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_info'
),
'star.edit.correct_value_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_placeholder'
),
'star.edit.max_stars_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_placeholder'
),
'star.take.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.helper'),
'star.take.star_sr': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.star_sr'),
'star.preview.correct_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.star.preview.correct_label'
),
'fill_blank.edit.accepted_answers_placeholder': t.get( 'fill_blank.edit.accepted_answers_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder' 'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
), ),
@@ -188,6 +211,54 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'fill_blank.review.your_answer_label': t.get( 'fill_blank.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label' 'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
), ),
'word_bank.edit.template_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_label'
),
'word_bank.edit.template_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_placeholder'
),
'word_bank.edit.template_helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_helper'
),
'word_bank.edit.no_blanks_warning': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.no_blanks_warning'
),
'word_bank.edit.correct_answers_heading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_answers_heading'
),
'word_bank.edit.blank_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.blank_label'
),
'word_bank.edit.correct_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_placeholder'
),
'word_bank.edit.distractors_heading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractors_heading'
),
'word_bank.edit.distractor_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractor_placeholder'
),
'word_bank.edit.add_distractor': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.add_distractor'
),
'word_bank.take.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.helper'
),
'word_bank.take.bank_heading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.bank_heading'
),
'word_bank.take.clear_blank_sr': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.clear_blank_sr'
),
'word_bank.preview.filled_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.preview.filled_label'
),
'word_bank.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.your_answer_label'
),
'word_bank.review.correct_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.correct_label'
),
'file_upload.edit.accepted_types_placeholder': t.get( 'file_upload.edit.accepted_types_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder' 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
), ),
@@ -235,6 +235,7 @@
case 'RADIO': case 'RADIO':
case 'TRUE_FALSE': case 'TRUE_FALSE':
case 'NUMERIC': case 'NUMERIC':
case 'STAR':
return true; return true;
case 'CHECKBOX': case 'CHECKBOX':
return answerValue.optionIds.length > 0; return answerValue.optionIds.length > 0;
@@ -244,6 +245,10 @@
return answerValue.text.trim().length > 0; return answerValue.text.trim().length > 0;
case 'FILL_BLANK': case 'FILL_BLANK':
return answerValue.values.length > 0; return answerValue.values.length > 0;
case 'WORD_BANK': {
const filled = answerValue.filledBlanks ?? [];
return filled.length > 0 && filled.every((v) => String(v).trim().length > 0);
}
case 'FILE_UPLOAD': case 'FILE_UPLOAD':
return !!answerValue.fileKey; return !!answerValue.fileKey;
case 'MATCHING': case 'MATCHING':
@@ -470,9 +475,12 @@
}); });
</script> </script>
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished} {#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
<div class="mb-6"> <div class="mb-6 flex min-w-0 items-center gap-3">
<Progress value={$questionnaireMetaData.progressValue} /> <span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
</span>
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
</div> </div>
{/if} {/if}
@@ -605,7 +613,9 @@
{/if} {/if}
<Preview <Preview
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)} questions={[...$questionnaire.questions].sort(
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
)}
questionnaireMetaData={$questionnaireMetaData} questionnaireMetaData={$questionnaireMetaData}
grades={$questionnaireMetaData.grades} grades={$questionnaireMetaData.grades}
disableGrading={true} disableGrading={true}
@@ -7,6 +7,7 @@ export { default as CourseFilterPopover } from './course-filter-popover.svelte';
export { default as CourseTagPicker } from './course-tag-picker.svelte'; export { default as CourseTagPicker } from './course-tag-picker.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte'; export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte'; export { default as CourseList } from './list.svelte';
export { default as CourseListRow } from './course-list-row.svelte';
export { default as CourseTagsOverflow } from './course-tags-overflow.svelte'; export { default as CourseTagsOverflow } from './course-tags-overflow.svelte';
export { default as CourseListLoader } from './list-loader.svelte'; export { default as CourseListLoader } from './list-loader.svelte';
export { default as NewCourseModal } from './new-course-modal.svelte'; export { default as NewCourseModal } from './new-course-modal.svelte';
@@ -3,6 +3,7 @@
import UploadVideo from './upload-video.svelte'; import UploadVideo from './upload-video.svelte';
import EmbedLink from './embed-link.svelte'; import EmbedLink from './embed-link.svelte';
import LibraryVideo from './library-video.svelte'; import LibraryVideo from './library-video.svelte';
import GoogleDriveVideo from './google-drive-video.svelte';
import * as CONSTANTS from './constants'; import * as CONSTANTS from './constants';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org'; import { isFreePlan } from '$lib/utils/store/org';
@@ -57,6 +58,9 @@
<UnderlineTabs.Content value="4" class="mt-3"> <UnderlineTabs.Content value="4" class="mt-3">
<LibraryVideo {lessonId} /> <LibraryVideo {lessonId} />
</UnderlineTabs.Content> </UnderlineTabs.Content>
<UnderlineTabs.Content value="5" class="mt-3">
<GoogleDriveVideo {lessonId} />
</UnderlineTabs.Content>
</UnderlineTabs.Root> </UnderlineTabs.Root>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -1,4 +1,5 @@
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink'; import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import LibraryIcon from '@lucide/svelte/icons/library'; import LibraryIcon from '@lucide/svelte/icons/library';
import VideoIcon from '@lucide/svelte/icons/video'; import VideoIcon from '@lucide/svelte/icons/video';
import YoutubeIcon from '@lucide/svelte/icons/youtube'; import YoutubeIcon from '@lucide/svelte/icons/youtube';
@@ -23,5 +24,10 @@ export const videoTabs = [
value: 4, value: 4,
title: 'course.navItem.lessons.materials.tabs.video.library', title: 'course.navItem.lessons.materials.tabs.video.library',
icon: LibraryIcon icon: LibraryIcon
},
{
value: 5,
title: 'course.navItem.lessons.materials.tabs.video.google_drive',
icon: HardDriveIcon
} }
]; ];
@@ -0,0 +1,152 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import CopyIcon from '@lucide/svelte/icons/copy';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import type { Lesson, LessonVideoType } from '$features/course/utils/types';
import type { TAssetProvider } from '@cio/utils/validation/assets';
import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api';
import { copyToClipboard, removeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import {
googleDrivePreviewUrl,
googleDriveThumbnailUrl,
pickGoogleDriveVideo
} from '$lib/utils/functions/google-drive-picker';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import { env } from '$env/dynamic/public';
interface Props {
lessonId?: string;
}
let { lessonId = '' }: Props = $props();
let isOpeningPicker = $state(false);
let errorMessage = $state('');
const clientId = $derived((env.PUBLIC_GOOGLE_PICKER_CLIENT_ID ?? '').trim());
const apiKey = $derived((env.PUBLIC_GOOGLE_PICKER_API_KEY ?? '').trim());
const isConfigured = $derived(Boolean(clientId && apiKey));
function isGoogleDriveLessonVideo(video: NonNullable<Lesson['videos']>[number]): boolean {
return (video as { type: string }).type === 'google_drive';
}
async function openPicker() {
errorMessage = '';
if (!isConfigured) {
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_not_configured');
return;
}
if (!lessonApi.lesson) return;
isOpeningPicker = true;
try {
const picked = await pickGoogleDriveVideo({ clientId, apiKey });
if (!picked) {
return;
}
const previewUrl = googleDrivePreviewUrl(picked.fileId);
const thumbnailUrl = googleDriveThumbnailUrl(picked.fileId);
const createdAt = new Date().toISOString();
const videoMetadata = {
createdAt,
title: picked.name,
googleDriveFileId: picked.fileId,
mimeType: picked.mimeType,
thumbnailUrl
};
const existingCount = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
const asset = await mediaApi.createAsset({
kind: 'video',
provider: 'google_drive' as TAssetProvider,
storageProvider: 'external',
storageKey: picked.fileId,
sourceUrl: previewUrl,
isExternal: true,
title: picked.name,
thumbnailUrl,
metadata: videoMetadata
});
if (asset && lessonId) {
await mediaApi.attachAsset(asset.id, {
targetType: 'lesson',
targetId: lessonId,
slotType: 'lesson_video',
position: existingCount
});
}
const newVideo = {
type: 'google_drive' as LessonVideoType,
link: previewUrl,
assetId: asset?.id,
fileName: picked.name,
metadata: videoMetadata
};
lessonApi.updateLessonState('videos', [newVideo as unknown as NonNullable<Lesson['videos']>[number]], {
append: true
});
lessonApi.isDirty = true;
} catch (error) {
console.error('Google Drive picker error:', error);
errorMessage = $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_error');
} finally {
isOpeningPicker = false;
}
}
</script>
<div class="flex flex-col gap-3">
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_hint')}
</p>
<Button type="button" onclick={openPicker} disabled={isOpeningPicker} class="w-fit">
{isOpeningPicker
? $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_opening')
: $t('course.navItem.lessons.materials.tabs.video.add_video.google_drive_choose')}
</Button>
{#if errorMessage}
<p class="ui:text-destructive text-sm">{errorMessage}</p>
{/if}
</div>
<p class="mt-4 pl-2 text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.videos_added')}:
<strong>
{lessonApi.lesson?.videos?.filter(isGoogleDriveLessonVideo).length || 0}
</strong>
</p>
<div class="">
{#each lessonApi.lesson?.videos || [] as video, index (video.assetId ?? `${video.type}-${index}-${video.link}`)}
{#if isGoogleDriveLessonVideo(video)}
<div class="flex items-center gap-1">
<Badge class="max-w-md truncate" variant="secondary">
{video.fileName || video.link}
</Badge>
<IconButton onclick={() => copyToClipboard(video.link)}>
<CopyIcon size={16} />
</IconButton>
<IconButton onclick={() => removeVideo(index)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
{/each}
</div>
@@ -9,6 +9,8 @@ type VideoMetadata = {
duration?: number; duration?: number;
createdAt?: string; createdAt?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
googleDriveFileId?: string;
mimeType?: string;
}; };
/** /**
@@ -23,6 +25,13 @@ export function getVideoThumbnailUrl(video: LessonVideo): string | null {
const meta = video.metadata as VideoMetadata | undefined; const meta = video.metadata as VideoMetadata | undefined;
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl; if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
} }
if (video.type === 'google_drive') {
const meta = video.metadata as VideoMetadata | undefined;
if (meta?.thumbnailUrl && typeof meta.thumbnailUrl === 'string') return meta.thumbnailUrl;
if (meta?.googleDriveFileId && typeof meta.googleDriveFileId === 'string') {
return `https://drive.google.com/thumbnail?id=${meta.googleDriveFileId}&sz=w400`;
}
}
return null; return null;
} }
@@ -67,6 +76,7 @@ export function getVideoTitle(video: LessonVideo, index: number): string {
if (video.type === 'youtube') return 'YouTube video'; if (video.type === 'youtube') return 'YouTube video';
if (video.type === 'generic') return 'Embedded video'; if (video.type === 'generic') return 'Embedded video';
if (video.type === 'google_drive') return 'Google Drive video';
return `Video ${index + 1}`; return `Video ${index + 1}`;
} }
@@ -2,7 +2,7 @@
import * as Item from '@cio/ui/base/item'; import * as Item from '@cio/ui/base/item';
import { Image } from '$features/ui'; import { Image } from '$features/ui';
import VideoIcon from '@lucide/svelte/icons/video'; import VideoIcon from '@lucide/svelte/icons/video';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'; import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
import VideoCardDropdown from './video-card-dropdown.svelte'; import VideoCardDropdown from './video-card-dropdown.svelte';
import { import {
getVideoThumbnailUrl, getVideoThumbnailUrl,
@@ -67,15 +67,19 @@
<Item.Content> <Item.Content>
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}> <Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
{#if isYoutubeWithLink} {#if isYoutubeWithLink}
<a <HoverableItem>
href={video.link} {#snippet children(isHovered)}
target="_blank" <a
rel="noopener noreferrer" href={video.link}
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5" target="_blank"
> rel="noopener noreferrer"
<span class="line-clamp-2">{title}</span> class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
<ExternalLinkIcon class="ui:text-muted-foreground size-3.5 shrink-0" aria-hidden="true" /> >
</a> <span class="line-clamp-2">{title}</span>
<ExternalLinkIcon {isHovered} size={14} class="ui:text-muted-foreground shrink-0" ariaHidden={true} />
</a>
{/snippet}
</HoverableItem>
{:else} {:else}
{title} {title}
{/if} {/if}
@@ -21,6 +21,8 @@
totalStudents?: number; totalStudents?: number;
isExplore?: boolean; isExplore?: boolean;
isLMS?: boolean; isLMS?: boolean;
href?: string;
showActions?: boolean;
tags?: Array<{ tags?: Array<{
id: string; id: string;
name: string; name: string;
@@ -40,10 +42,17 @@
totalStudents = 0, totalStudents = 0,
isExplore = false, isExplore = false,
isLMS = false, isLMS = false,
href,
showActions = true,
tags = [] tags = []
}: Props = $props(); }: Props = $props();
function handleRowClick() { function handleRowClick() {
if (href) {
goto(resolve(href, {}));
return;
}
if (isExplore) { if (isExplore) {
goto(resolve(`/course/${slug}`, {})); goto(resolve(`/course/${slug}`, {}));
return; return;
@@ -104,7 +113,7 @@
</Table.Cell> </Table.Cell>
{/if} {/if}
{/if} {/if}
{#if !isLMS} {#if !isLMS && showActions}
<Table.Cell class="text-center"> <Table.Cell class="text-center">
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -3,11 +3,12 @@
import type { Feed } from '$features/course/utils/types'; import type { Feed } from '$features/course/utils/types';
import { newsfeedApi } from '$features/course/api'; import { newsfeedApi } from '$features/course/api';
import { getNewsfeedReactionCounts, getSelectedNewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
import DeleteConfirmation from './delete-confirmation.svelte'; import DeleteConfirmation from './delete-confirmation.svelte';
import type { NewsfeedCommentsByFeedId } from '$features/course/api'; import type { NewsfeedCommentsByFeedId } from '$features/course/api';
import Header from './header.svelte'; import Header from './header.svelte';
import Reactions from './reactions.svelte'; import { NewsfeedReactions } from '@cio/ui/custom/newsfeed-reactions';
import Comments from './comments.svelte'; import Comments from './comments.svelte';
interface Props { interface Props {
@@ -64,6 +65,9 @@
deleteFeed(feed.id); deleteFeed(feed.id);
}; };
const reactionCounts = $derived(getNewsfeedReactionCounts(feed.reaction));
const selectedReactionType = $derived(getSelectedNewsfeedReactionType(feed.reaction, author.id));
onMount(() => { onMount(() => {
if (isActive) { if (isActive) {
const el = document.getElementById(feed.id); const el = document.getElementById(feed.id);
@@ -84,7 +88,14 @@
> >
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} /> <Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
<Reactions {feed} authorId={author.id} {isReacting} onReact={handleReact} /> <div class="px-3 pt-1 pb-2">
<NewsfeedReactions
{reactionCounts}
{selectedReactionType}
disabled={isReacting || !author.id}
onReactionToggle={handleReact}
/>
</div>
<Comments <Comments
{courseId} {courseId}
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Avatar from '@cio/ui/base/avatar';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import UserIcon from '@lucide/svelte/icons/user';
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
@@ -24,11 +26,17 @@
<div class="mb-2 flex justify-between"> <div class="mb-2 flex justify-between">
<span class="flex items-center gap-3"> <span class="flex items-center gap-3">
<div class="h-9 w-9"> <div class="h-9 w-9">
<img <Avatar.Root class="h-full w-full">
src={feed.authorAvatarUrl || ''} <Avatar.Image
alt={$t('course.navItem.news_feed.user_avatar_alt')} src={feed.authorAvatarUrl ?? ''}
class="h-full w-full rounded-full object-cover" alt={$t('course.navItem.news_feed.user_avatar_alt')}
/> loading="lazy"
decoding="async"
/>
<Avatar.Fallback aria-hidden="true">
<UserIcon class="text-muted-foreground h-5 w-5" />
</Avatar.Fallback>
</Avatar.Root>
</div> </div>
<span> <span>
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p> <p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
@@ -1,43 +0,0 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import type { Feed } from '$features/course/utils/types';
interface Props {
feed: Feed;
authorId: string;
isReacting?: boolean;
onReact: (reactionType: string) => void;
}
let { feed, authorId, isReacting = false, onReact }: Props = $props();
const reactions: Record<string, string> = {
smile: '😀',
thumbsup: '👍',
thumbsdown: '👎',
clap: '👏'
};
</script>
<!-- Reactions -->
<div class="flex items-center gap-4 px-3">
<div class="flex items-center gap-1">
{#each Object.keys(feed.reaction || {}) as reactionType}
{#if reactions[reactionType]}
{@const isAuthorReacted = (feed.reaction?.[reactionType] || []).includes(authorId)}
<Button
variant={isAuthorReacted ? 'light-default' : 'ghost'}
size="icon-sm"
class="size-unset! px-5! py-0!"
disabled={isReacting}
onclick={() => onReact(reactionType)}
>
{reactions[reactionType]}
{#if feed.reaction?.[reactionType]?.length >= 1}
<span class="text-xs">{feed.reaction?.[reactionType]?.length}</span>
{/if}
</Button>
{/if}
{/each}
</div>
</div>
@@ -13,12 +13,10 @@
import { peopleApi } from '$features/course/api'; import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte'; import { orgApi } from '$features/org/api/org.svelte';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils'; import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import { profile } from '$lib/utils/store/user';
import type { OrgTeamMember } from '$lib/utils/types/org'; import type { OrgTeamMember } from '$lib/utils/types/org';
import type { OrgStudent, Tutor } from './types'; import type { OrgStudent, Tutor } from '$features/people/utils/types';
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
import TutorSelectSection from './tutor-select-section.svelte';
import ExistingStudentsSection from './existing-students-section.svelte';
import BulkEmailSection from './bulk-email-section.svelte';
let tutors = $state<Tutor[]>([]); let tutors = $state<Tutor[]>([]);
let selectedIds = $state<string[]>([]); let selectedIds = $state<string[]>([]);
@@ -87,6 +85,56 @@
}); });
} }
async function handleStudentSearch(value: string) {
const orgId = $currentOrg.id;
if (!orgId) {
return;
}
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(
orgId,
{
...DEFAULT_ORG_AUDIENCE_QUERY,
search: value.trim() || undefined
},
{ abortPrevious: true }
);
} finally {
isLoadingStudents = false;
}
}
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
const result = await orgApi.assignAudienceToCourses({
profileIds,
courseIds: [courseId],
sendEmail
});
if (!result) {
return;
}
await courseApi.refreshCourse(courseId, $profile.id);
}
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
const response = await orgApi.importAudienceMembers({
recipientCsv,
courseIds: [courseId],
sendEmail
});
if (!response) {
return;
}
await courseApi.refreshCourse(courseId, $profile.id);
}
async function onSubmit() { async function onSubmit() {
if (!selectedTutors.length) { if (!selectedTutors.length) {
goto(resolve(page.url.pathname, {})); goto(resolve(page.url.pathname, {}));
@@ -144,7 +192,12 @@
<UnderlineTabs.Content value="tutors"> <UnderlineTabs.Content value="tutors">
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<TutorSelectSection {tutors} bind:selectedIds isLoading={orgApi.isLoading} /> <TutorSelectSection
{tutors}
bind:selectedIds
isLoading={orgApi.isLoading}
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
/>
<div class="mt-5 flex flex-row-reverse items-center gap-2"> <div class="mt-5 flex flex-row-reverse items-center gap-2">
<Button variant="secondary" type="button" onclick={onSubmit} loading={peopleApi.isLoading}> <Button variant="secondary" type="button" onclick={onSubmit} loading={peopleApi.isLoading}>
@@ -156,8 +209,13 @@
<UnderlineTabs.Content value="students"> <UnderlineTabs.Content value="students">
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} /> <ExistingStudentsSection
<BulkEmailSection {courseId} /> students={availableStudents}
isLoading={isLoadingStudents}
onSearchValueChange={handleStudentSearch}
onAssign={assignExistingStudents}
/>
<BulkEmailSection onInvite={inviteNewStudents} />
</div> </div>
</UnderlineTabs.Content> </UnderlineTabs.Content>
</UnderlineTabs.Root> </UnderlineTabs.Root>

Some files were not shown because too many files have changed in this diff Show More