feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
---
|
||||
name: Lesson AI Features
|
||||
overview: Add credit-based "Listen to Lesson" (TTS) and "Summarize" (LLM) features to lessons, with a dedicated AI credit system, Polar.sh credit purchases, and a teacher-managed AI tab in edit mode.
|
||||
todos:
|
||||
- id: openai-dep
|
||||
content: Install openai package in apps/api and add OPENAI_API_KEY to env schema
|
||||
status: pending
|
||||
- id: db-tables
|
||||
content: Add lesson_ai_cache, organization_ai_usage, organization_ai_credit_purchase tables to schema.ts and generate migration
|
||||
status: pending
|
||||
- id: db-queries
|
||||
content: Create query functions for AI cache, AI usage tracking, and AI credit balance computation
|
||||
status: pending
|
||||
- id: ai-credits-constants
|
||||
content: Create AI credit costs and per-plan limits in packages/utils/src/plans/ai-credits.ts
|
||||
status: pending
|
||||
- id: error-codes
|
||||
content: Add AI-related error codes to ErrorCodes
|
||||
status: pending
|
||||
- id: ai-credit-service
|
||||
content: Create ai-credits.ts service for balance checks, deduction, and Polar purchase handling
|
||||
status: pending
|
||||
- id: ai-service
|
||||
content: Create lesson-ai.ts service with summarizeLesson and generateLessonAudio
|
||||
status: pending
|
||||
- id: validation
|
||||
content: Add Zod schemas for AI endpoints (summarize, audio, cost-estimate, credit-balance)
|
||||
status: pending
|
||||
- id: api-routes
|
||||
content: Add AI lesson routes and AI credit routes to the API
|
||||
status: pending
|
||||
- id: polar-webhook
|
||||
content: Extend Polar webhook to handle AI credit pack purchases
|
||||
status: pending
|
||||
- id: api-build
|
||||
content: Build API and verify RPC types propagate
|
||||
status: pending
|
||||
- id: fe-types
|
||||
content: Add request/response types to course/utils/types.ts
|
||||
status: pending
|
||||
- id: fe-api-methods
|
||||
content: Add AI methods to LessonApi class and create AiCreditsApi class
|
||||
status: pending
|
||||
- id: fe-ai-tab
|
||||
content: Create lesson AI tab component for edit mode (generate/regenerate TTS and summaries)
|
||||
status: pending
|
||||
- id: fe-student-view
|
||||
content: Create student-facing Listen and Summary components for view mode
|
||||
status: pending
|
||||
- id: fe-credit-ui
|
||||
content: Add AI credit balance display and buy-credits flow in settings
|
||||
status: pending
|
||||
- id: fe-translations
|
||||
content: Add translation keys for all AI feature strings
|
||||
status: pending
|
||||
- id: fe-integrate
|
||||
content: Wire everything into lesson.svelte, export from barrels, final integration
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Lesson AI Features: Listen to Lesson and Summarize
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph teacherView [Teacher - Edit Mode]
|
||||
AITab["AI tab in lesson editor"]
|
||||
CostPreview["Credit cost per lesson"]
|
||||
GenerateBtn["Generate / Regenerate buttons"]
|
||||
end
|
||||
|
||||
subgraph studentView [Student - View Mode]
|
||||
ListenBtn["Listen to lesson button"]
|
||||
SummaryPanel["AI Summary panel"]
|
||||
AudioPlayer["Audio player"]
|
||||
end
|
||||
|
||||
subgraph creditSystem [AI Credit System]
|
||||
BalanceCheck["Balance check"]
|
||||
PlanCredits["Plan monthly credits"]
|
||||
PurchasedCredits["Purchased credit packs"]
|
||||
PolarCheckout["Polar.sh checkout"]
|
||||
end
|
||||
|
||||
subgraph api [API - Hono]
|
||||
SummarizeRoute["POST lesson/:id/summarize"]
|
||||
AudioRoute["POST lesson/:id/audio"]
|
||||
CostRoute["GET lesson/:id/ai-cost"]
|
||||
BalanceRoute["GET org/ai-credits"]
|
||||
PurchaseRoute["Polar webhook"]
|
||||
AIService["lesson-ai.ts"]
|
||||
CreditService["ai-credits.ts"]
|
||||
end
|
||||
|
||||
subgraph storage [Storage]
|
||||
Cache["lesson_ai_cache"]
|
||||
UsageTable["organization_ai_usage"]
|
||||
PurchaseTable["organization_ai_credit_purchase"]
|
||||
S3["S3 audio files"]
|
||||
end
|
||||
|
||||
subgraph openai [OpenAI]
|
||||
ChatAPI["GPT-4o-mini"]
|
||||
TTSAPI["tts-1"]
|
||||
end
|
||||
|
||||
AITab --> CostPreview
|
||||
AITab --> GenerateBtn
|
||||
GenerateBtn -->|RPC| SummarizeRoute
|
||||
GenerateBtn -->|RPC| AudioRoute
|
||||
ListenBtn --> AudioPlayer
|
||||
studentView -->|"reads cached"| Cache
|
||||
|
||||
SummarizeRoute --> CreditService
|
||||
AudioRoute --> CreditService
|
||||
CreditService --> BalanceCheck
|
||||
BalanceCheck --> PlanCredits
|
||||
BalanceCheck --> PurchasedCredits
|
||||
PolarCheckout -->|webhook| PurchaseRoute
|
||||
PurchaseRoute --> PurchaseTable
|
||||
|
||||
CreditService --> AIService
|
||||
AIService --> Cache
|
||||
AIService --> UsageTable
|
||||
AIService -->|summary| ChatAPI
|
||||
AIService -->|audio| TTSAPI
|
||||
AIService -->|upload| S3
|
||||
```
|
||||
|
||||
## Credit System Design
|
||||
|
||||
### Credit Costs
|
||||
|
||||
| Action | Credits | Approx. OpenAI cost |
|
||||
|--------|---------|---------------------|
|
||||
| Generate summary | 1 | ~$0.0003 |
|
||||
| Generate TTS audio | 5 | ~$0.05-0.08 |
|
||||
| Regenerate (same costs) | same | same |
|
||||
|
||||
### Per-Plan Monthly AI Credits
|
||||
|
||||
| Plan | Monthly AI Credits |
|
||||
|------|-------------------|
|
||||
| BASIC (Free) | 5 |
|
||||
| EARLY_ADOPTER | 50 |
|
||||
| ENTERPRISE | 500 |
|
||||
|
||||
### Purchased Credits
|
||||
|
||||
- One-time credit packs via Polar.sh (e.g., 50 credits, 200 credits)
|
||||
- Purchased credits never expire
|
||||
- Plan credits are consumed first each month; purchased credits are used only when plan credits run out
|
||||
- New Polar products need to be created for credit packs
|
||||
|
||||
### Balance Computation
|
||||
|
||||
```
|
||||
planCreditsRemaining = planMonthlyLimit - SUM(ai_usage WHERE source='plan' AND this billing month)
|
||||
purchasedCreditsRemaining = SUM(ai_credit_purchase.credits) - SUM(ai_usage WHERE source='purchased')
|
||||
totalAvailable = planCreditsRemaining + purchasedCreditsRemaining
|
||||
```
|
||||
|
||||
When consuming credits: deduct from plan credits first. If plan credits are exhausted, deduct from purchased pool. The `source` column on `organization_ai_usage` tracks which pool was charged.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend Infrastructure
|
||||
|
||||
### 1a. OpenAI dependency and env config
|
||||
|
||||
- Install `openai` (latest) in [apps/api/package.json](apps/api/package.json)
|
||||
- Add `OPENAI_API_KEY` (optional string) to the Zod env schema in [apps/api/src/config/env.ts](apps/api/src/config/env.ts)
|
||||
|
||||
### 1b. Database tables
|
||||
|
||||
Add to [packages/db/src/schema.ts](packages/db/src/schema.ts) and generate a migration.
|
||||
|
||||
**Enum:**
|
||||
|
||||
```typescript
|
||||
export const lessonAiFeatureType = pgEnum('LESSON_AI_FEATURE_TYPE', ['summary', 'audio']);
|
||||
export const aiCreditSource = pgEnum('AI_CREDIT_SOURCE', ['plan', 'purchased']);
|
||||
```
|
||||
|
||||
**`lesson_ai_cache`** -- stores generated summaries and audio per lesson+locale:
|
||||
|
||||
```typescript
|
||||
export const lessonAiCache = pgTable('lesson_ai_cache', {
|
||||
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
|
||||
lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }),
|
||||
locale: locale().default('en').notNull(),
|
||||
featureType: lessonAiFeatureType('feature_type').notNull(),
|
||||
content: text(), // markdown summary
|
||||
audioAssetKey: text('audio_asset_key'), // S3 key for TTS audio
|
||||
contentHash: text('content_hash').notNull(), // hash of source lesson HTML
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
}, (table) => [
|
||||
unique('unique_lesson_locale_feature').on(table.lessonId, table.locale, table.featureType)
|
||||
]);
|
||||
```
|
||||
|
||||
**`organization_ai_usage`** -- tracks each AI generation event:
|
||||
|
||||
```typescript
|
||||
export const organizationAiUsage = pgTable('organization_ai_usage', {
|
||||
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
|
||||
organizationId: uuid('organization_id').notNull().references(() => organization.id),
|
||||
lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }),
|
||||
featureType: lessonAiFeatureType('feature_type').notNull(),
|
||||
creditsConsumed: integer('credits_consumed').default(0).notNull(),
|
||||
source: aiCreditSource('source').notNull(), // 'plan' or 'purchased'
|
||||
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`).notNull()
|
||||
}, (table) => [
|
||||
index('idx_org_ai_usage_org_created').on(table.organizationId, table.createdAt)
|
||||
]);
|
||||
```
|
||||
|
||||
**`organization_ai_credit_purchase`** -- tracks Polar credit pack purchases:
|
||||
|
||||
```typescript
|
||||
export const organizationAiCreditPurchase = pgTable('organization_ai_credit_purchase', {
|
||||
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
|
||||
organizationId: uuid('organization_id').notNull().references(() => organization.id),
|
||||
credits: integer('credits').notNull(),
|
||||
polarOrderId: text('polar_order_id'),
|
||||
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`).notNull()
|
||||
});
|
||||
```
|
||||
|
||||
### 1c. Database queries
|
||||
|
||||
New file: `packages/db/src/queries/organization/ai-credits.ts`
|
||||
|
||||
- `getAiCreditsUsedThisMonth(orgId, source)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='plan'` AND `createdAt >= billingPeriodStart`
|
||||
- `getTotalPurchasedCredits(orgId)` -- SUM of `credits` from `organization_ai_credit_purchase`
|
||||
- `getTotalPurchasedCreditsUsed(orgId)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='purchased'`
|
||||
- `recordAiUsage(data)` -- insert into `organization_ai_usage`
|
||||
- `recordAiCreditPurchase(data)` -- insert into `organization_ai_credit_purchase`
|
||||
|
||||
New file: `packages/db/src/queries/lesson/ai-cache.ts`
|
||||
|
||||
- `getLessonAiCache(lessonId, locale, featureType)` -- get cached entry
|
||||
- `upsertLessonAiCache(data)` -- insert or update on conflict
|
||||
|
||||
### 1d. AI credit constants
|
||||
|
||||
New file: [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts)
|
||||
|
||||
```typescript
|
||||
export const AI_FEATURE_CREDIT_COST = {
|
||||
summary: 1,
|
||||
audio: 5,
|
||||
} as const;
|
||||
|
||||
export const AI_CREDITS_BY_PLAN: Record<string, { monthlyCredits: number }> = {
|
||||
[PLAN.BASIC]: { monthlyCredits: 5 },
|
||||
[PLAN.EARLY_ADOPTER]: { monthlyCredits: 50 },
|
||||
[PLAN.ENTERPRISE]: { monthlyCredits: 500 },
|
||||
};
|
||||
```
|
||||
|
||||
Re-export from `packages/utils/src/plans/index.ts`.
|
||||
|
||||
### 1e. Error codes
|
||||
|
||||
Add to [packages/utils/src/constants/error-codes.ts](packages/utils/src/constants/error-codes.ts):
|
||||
|
||||
```typescript
|
||||
AI_CREDITS_EXHAUSTED: 'AI_CREDITS_EXHAUSTED',
|
||||
AI_FEATURE_UNAVAILABLE: 'AI_FEATURE_UNAVAILABLE',
|
||||
AI_GENERATION_FAILED: 'AI_GENERATION_FAILED',
|
||||
```
|
||||
|
||||
### 1f. AI credit service
|
||||
|
||||
New file: `apps/api/src/services/organization/ai-credits.ts`
|
||||
|
||||
- `getAiCreditBalance(orgId)` -- computes `planCreditsRemaining`, `purchasedCreditsRemaining`, `totalAvailable`, `monthlyLimit`, and returns the full balance object
|
||||
- `assertAiCreditsAvailable(orgId, featureType)` -- checks totalAvailable >= cost, throws `AI_CREDITS_EXHAUSTED` (429) if insufficient
|
||||
- `deductAiCredits(orgId, lessonId, featureType)` -- determines source (plan first, then purchased), inserts usage row
|
||||
- `addPurchasedCredits(orgId, credits, polarOrderId)` -- inserts into `organization_ai_credit_purchase`
|
||||
|
||||
### 1g. Lesson AI service
|
||||
|
||||
New file: `apps/api/src/services/lesson/lesson-ai.ts`
|
||||
|
||||
**`summarizeLesson(lessonId, locale, orgId)`**
|
||||
1. Fetch lesson content from `lesson_language` for locale
|
||||
2. Strip HTML to plain text, compute content hash
|
||||
3. Check `lesson_ai_cache` -- if hash matches, return cached summary
|
||||
4. Call `assertAiCreditsAvailable(orgId, 'summary')`
|
||||
5. Call OpenAI Chat Completions (`gpt-4o-mini`) with summarization prompt
|
||||
6. Upsert `lesson_ai_cache` with summary + hash
|
||||
7. Call `deductAiCredits(orgId, lessonId, 'summary')`
|
||||
8. Return summary
|
||||
|
||||
**`generateLessonAudio(lessonId, locale, orgId)`**
|
||||
1. Fetch lesson content, strip HTML, compute hash
|
||||
2. Check cache -- if hash matches and audio key exists, return presigned URL
|
||||
3. Call `assertAiCreditsAvailable(orgId, 'audio')`
|
||||
4. Chunk text into segments under 4096 chars
|
||||
5. Call OpenAI TTS (`tts-1`, voice `alloy`) for each chunk
|
||||
6. Concatenate audio buffers, upload to S3
|
||||
7. Upsert cache with audio key + hash
|
||||
8. Call `deductAiCredits(orgId, lessonId, 'audio')`
|
||||
9. Return presigned URL
|
||||
|
||||
**`estimateLessonAiCost(lessonId, locale)`**
|
||||
1. Fetch lesson content, check what's already cached
|
||||
2. Return `{ summaryCost, audioCost, totalCost, isStale }` -- 0 if already cached and fresh
|
||||
|
||||
**`getLessonAiStatus(lessonId, locale)`**
|
||||
1. Check cache for both summary and audio
|
||||
2. Return `{ hasSummary, hasAudio, isSummaryStale, isAudioStale, summary?, audioUrl? }`
|
||||
|
||||
Shared util: `stripHtmlToText(html)` using regex or a lightweight lib.
|
||||
|
||||
### 1h. API routes
|
||||
|
||||
**Extend lesson router** in [apps/api/src/routes/course/lesson.ts](apps/api/src/routes/course/lesson.ts):
|
||||
|
||||
- `POST /:lessonId/summarize` -- auth + courseMember, role 1/2 only (teacher/admin), calls `summarizeLesson`
|
||||
- `POST /:lessonId/audio` -- auth + courseMember, role 1/2 only, calls `generateLessonAudio`
|
||||
- `GET /:lessonId/ai-status` -- auth + courseMember, calls `getLessonAiStatus` (used by both edit and view mode)
|
||||
- `GET /:lessonId/ai-cost` -- auth + courseMember, role 1/2 only, calls `estimateLessonAiCost`
|
||||
|
||||
**New org AI credit routes** (add to [apps/api/src/routes/organization/organization.ts](apps/api/src/routes/organization/organization.ts) or a new `ai-credits` sub-router):
|
||||
|
||||
- `GET /ai-credits` -- auth, returns `getAiCreditBalance(orgId)`
|
||||
|
||||
### 1i. Validation schemas
|
||||
|
||||
Add to `packages/utils/src/validation/lesson/`:
|
||||
|
||||
- `ZLessonAiParam` -- `{ lessonId: z.string().uuid() }`
|
||||
- `ZLessonAiQuery` -- `{ locale: z.enum([...]).optional().default('en') }`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Polar Credit Purchases
|
||||
|
||||
### 2a. Create Polar products
|
||||
|
||||
Create credit pack products in Polar dashboard (not code -- manual setup):
|
||||
- "50 AI Credits" -- one-time purchase
|
||||
- "200 AI Credits" -- one-time purchase
|
||||
|
||||
### 2b. Credit pack constants
|
||||
|
||||
Add product IDs to [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts):
|
||||
|
||||
```typescript
|
||||
export const AI_CREDIT_PACKS = {
|
||||
SMALL: { credits: 50, productId: '<polar-product-id>' },
|
||||
LARGE: { credits: 200, productId: '<polar-product-id>' },
|
||||
};
|
||||
```
|
||||
|
||||
### 2c. Polar webhook extension
|
||||
|
||||
Extend [apps/dashboard/src/routes/api/polar/webhook/+server.ts](apps/dashboard/src/routes/api/polar/webhook/+server.ts):
|
||||
|
||||
- Add a new `order.created` (or `checkout.updated` with `status === 'succeeded'`) handler
|
||||
- Match product ID against `AI_CREDIT_PACKS` to determine credits
|
||||
- Call a new API route to add credits: `POST /organization/ai-credits/purchase`
|
||||
- That route calls `addPurchasedCredits(orgId, credits, polarOrderId)`
|
||||
|
||||
### 2d. Checkout flow
|
||||
|
||||
Reuse the existing Polar checkout pattern from [apps/dashboard/src/routes/api/polar/subscribe/+server.ts](apps/dashboard/src/routes/api/polar/subscribe/+server.ts) -- create a similar `GET /api/polar/ai-credits` endpoint that creates a one-time checkout (not subscription) with `metadata: { orgId, type: 'ai-credits', credits: N }`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Frontend -- Teacher Edit Mode
|
||||
|
||||
### 3a. Types
|
||||
|
||||
Add to [apps/dashboard/src/lib/features/course/utils/types.ts](apps/dashboard/src/lib/features/course/utils/types.ts):
|
||||
|
||||
```typescript
|
||||
export type SummarizeLessonRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].summarize.$post;
|
||||
export type GenerateLessonAudioRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].audio.$post;
|
||||
export type GetLessonAiStatusRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-status']['$get'];
|
||||
export type GetLessonAiCostRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-cost']['$get'];
|
||||
```
|
||||
|
||||
### 3b. LessonApi extensions
|
||||
|
||||
Add to [apps/dashboard/src/lib/features/course/api/lesson.svelte.ts](apps/dashboard/src/lib/features/course/api/lesson.svelte.ts):
|
||||
|
||||
- State: `aiStatus`, `aiCost`, `isSummarizing`, `isGeneratingAudio`
|
||||
- Methods: `getAiStatus(courseId, lessonId, locale)`, `getAiCost(courseId, lessonId, locale)`, `summarize(courseId, lessonId, locale)`, `generateAudio(courseId, lessonId, locale)`
|
||||
|
||||
### 3c. AI credit API class
|
||||
|
||||
New file: `apps/dashboard/src/lib/features/course/api/ai-credits.svelte.ts`
|
||||
|
||||
- State: `balance` (plan remaining, purchased remaining, total)
|
||||
- Methods: `getBalance()`, `purchaseCredits(packId)` (redirects to Polar checkout)
|
||||
|
||||
### 3d. Lesson AI tab (edit mode)
|
||||
|
||||
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/ai-tab.svelte`
|
||||
|
||||
This is a new tab (value `5`) added to the lesson edit tabs in [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts) alongside Note, Slide, Video, Document. Uses a sparkle/wand icon.
|
||||
|
||||
Layout:
|
||||
- **Credit balance bar** at top: "AI Credits: 42 remaining (plan: 35, purchased: 7)" with a "Buy more" link to settings
|
||||
- **Summary section:**
|
||||
- Status: "Not generated" / "Generated" / "Stale (content changed)"
|
||||
- "Generate Summary" button showing cost (e.g., "1 credit")
|
||||
- If generated: preview of the summary text, "Regenerate" button
|
||||
- **Audio section:**
|
||||
- Status: same pattern
|
||||
- "Generate Audio" button showing cost (e.g., "5 credits")
|
||||
- If generated: inline audio player, "Regenerate" button
|
||||
- If stale: warning that content has changed since last generation
|
||||
|
||||
### 3e. Integrate AI tab
|
||||
|
||||
In [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte):
|
||||
- Add AI tab to the `UnderlineTabs` in edit mode
|
||||
- Load `aiStatus` and `aiCost` when entering the AI tab (lazy load)
|
||||
- Load `aiCredits.balance` when entering the AI tab
|
||||
|
||||
In [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts):
|
||||
- Add `{ label: 'course.navItem.lessons.materials.tabs.ai.title', icon: SparklesIcon, value: 5, badgeValue: 0 }`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Frontend -- Student View Mode
|
||||
|
||||
### 4a. Student toolbar
|
||||
|
||||
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-ai-toolbar.svelte`
|
||||
|
||||
Placed in the lesson view mode block in [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte), between the note content and the Comments section. Shown only when `aiStatus` has generated content.
|
||||
|
||||
- **"Listen to lesson"** button (headphones icon, dark bg) -- shown only when `aiStatus.hasAudio` is true. Toggles an inline audio player.
|
||||
- **"Summarize"** button (zap icon, outline) -- shown only when `aiStatus.hasSummary` is true. Toggles the summary panel below.
|
||||
|
||||
### 4b. Audio player
|
||||
|
||||
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-audio-player.svelte`
|
||||
|
||||
- Native `<audio>` with custom play/pause/progress controls
|
||||
- Receives `audioUrl` from `lessonApi.aiStatus`
|
||||
|
||||
### 4c. Summary panel
|
||||
|
||||
New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-summary-panel.svelte`
|
||||
|
||||
- Styled card matching the screenshot: rounded corners, "AI SUMMARY" heading in gold/amber, bullet-point list
|
||||
- Renders markdown bullet points from the cached summary
|
||||
- Collapsible (toggled by the Summarize button)
|
||||
|
||||
### 4d. Auto-load AI status
|
||||
|
||||
In [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte), when in view mode and lesson is loaded, call `lessonApi.getAiStatus(courseId, lessonId, locale)` to determine which buttons to show.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Settings -- AI Credits Management
|
||||
|
||||
### 5a. AI credits settings section
|
||||
|
||||
Either add a section to the existing billing page at [billing.svelte](apps/dashboard/src/lib/features/settings/pages/billing.svelte), or create a new settings tab. Shows:
|
||||
|
||||
- Current AI credit balance (plan + purchased breakdown)
|
||||
- Monthly plan allowance and usage bar
|
||||
- "Buy Credits" section with credit pack cards (50 / 200) linking to Polar checkout
|
||||
- Usage history (recent AI generations with lesson name, type, credits, date)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Translations and Polish
|
||||
|
||||
Add keys to [en.json](apps/dashboard/src/lib/utils/translations/en.json):
|
||||
|
||||
- `course.navItem.lessons.materials.tabs.ai.title` -- "AI"
|
||||
- `course.navItem.lessons.ai.summary_status_*` -- not generated / generated / stale
|
||||
- `course.navItem.lessons.ai.audio_status_*` -- same
|
||||
- `course.navItem.lessons.ai.generate_summary` -- "Generate Summary"
|
||||
- `course.navItem.lessons.ai.generate_audio` -- "Generate Audio"
|
||||
- `course.navItem.lessons.ai.regenerate` -- "Regenerate"
|
||||
- `course.navItem.lessons.ai.credits_remaining` -- "{count} credits remaining"
|
||||
- `course.navItem.lessons.ai.buy_more` -- "Buy more credits"
|
||||
- `course.navItem.lessons.ai.credit_cost` -- "{count} credit(s)"
|
||||
- `course.navItem.lessons.ai.content_stale` -- "Content has changed since last generation"
|
||||
- `course.navItem.lessons.ai.listen_to_lesson` -- "Listen to lesson"
|
||||
- `course.navItem.lessons.ai.summarize` -- "Summarize"
|
||||
- `course.navItem.lessons.ai.summary_heading` -- "AI Summary"
|
||||
- `settings.billing.ai_credits.*` -- balance, purchase, usage history strings
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
**New files:**
|
||||
- `packages/utils/src/plans/ai-credits.ts`
|
||||
- `packages/db/src/queries/organization/ai-credits.ts`
|
||||
- `packages/db/src/queries/lesson/ai-cache.ts`
|
||||
- `apps/api/src/services/organization/ai-credits.ts`
|
||||
- `apps/api/src/services/lesson/lesson-ai.ts`
|
||||
- `apps/dashboard/src/lib/features/course/api/ai-credits.svelte.ts`
|
||||
- `apps/dashboard/src/lib/features/course/components/lesson/ai/ai-tab.svelte`
|
||||
- `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-ai-toolbar.svelte`
|
||||
- `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-audio-player.svelte`
|
||||
- `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-summary-panel.svelte`
|
||||
|
||||
**Modified files:**
|
||||
- `packages/db/src/schema.ts` -- 3 new tables, 2 new enums
|
||||
- `packages/utils/src/constants/error-codes.ts` -- new error codes
|
||||
- `packages/utils/src/plans/index.ts` -- re-export ai-credits
|
||||
- `apps/api/src/config/env.ts` -- OPENAI_API_KEY
|
||||
- `apps/api/src/routes/course/lesson.ts` -- 4 new routes
|
||||
- `apps/api/src/routes/organization/organization.ts` -- AI credit balance route
|
||||
- `apps/dashboard/src/routes/api/polar/webhook/+server.ts` -- credit purchase handler
|
||||
- `apps/dashboard/src/lib/features/course/pages/lesson.svelte` -- AI tab + student toolbar
|
||||
- `apps/dashboard/src/lib/features/course/components/lesson/constants.ts` -- new tab entry
|
||||
- `apps/dashboard/src/lib/features/course/components/lesson/index.ts` -- new exports
|
||||
- `apps/dashboard/src/lib/features/course/api/lesson.svelte.ts` -- AI methods
|
||||
- `apps/dashboard/src/lib/features/course/utils/types.ts` -- new request types
|
||||
- `apps/dashboard/src/lib/utils/translations/en.json` -- new keys
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
name: Public API Tests
|
||||
overview: Add a Vitest suite for `/public-api/v1` with explicit tests that Bearer API keys, scopes, and org context are enforced, plus strict JSON assertions on success and error responses. DB and external I/O stay mocked; one small Hono app uses real middleware with a mocked auth service to prove the full chain.
|
||||
todos:
|
||||
- id: vitest-config
|
||||
content: Update vitest.config.ts with @api path alias
|
||||
status: pending
|
||||
- id: courses-routes
|
||||
content: Create courses route tests with strict response body assertions
|
||||
status: pending
|
||||
- id: audience-routes
|
||||
content: Create audience route tests with strict response body assertions
|
||||
status: pending
|
||||
- id: auth-chain
|
||||
content: Add public-api-auth-chain tests (real middleware + mocked authenticate + mocked services)
|
||||
status: pending
|
||||
- id: automation-key-mw
|
||||
content: Create automation-key middleware tests (Bearer + touch + JSON error shape)
|
||||
status: pending
|
||||
- id: automation-scopes-mw
|
||||
content: Create automation-key-scopes tests (403 without public_api:*, JSON shape)
|
||||
status: pending
|
||||
- id: course-service
|
||||
content: Create v1 course service unit tests (returns + mock call args)
|
||||
status: pending
|
||||
- id: audience-service
|
||||
content: Create v1 audience service unit tests (returns + mock call args)
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Public API Test Suite
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers of tests, each mocking only the layer below it.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
RouteTests["Route Tests\n(mock middleware + services)"]
|
||||
MiddlewareTests["Middleware Tests\n(mock DB/auth-key service)"]
|
||||
ServiceTests["Service Tests\n(mock DB queries)"]
|
||||
|
||||
RouteTests --> MiddlewareTests
|
||||
RouteTests --> ServiceTests
|
||||
MiddlewareTests --> ServiceTests
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Coverage vs codebase (audit)
|
||||
|
||||
In this repository, **public API** means the org automation key surface at `/public-api/v1` ([apps/api/src/app.ts](apps/api/src/app.ts)). Nothing else is mounted there; [apps/api/src/routes/v1/index.ts](apps/api/src/routes/v1/index.ts) only wires `/audience` and `/courses`. Other Hono routes (for example `/organization`, `/course`) use session or dashboard flows and are out of scope for this plan.
|
||||
|
||||
Full endpoint inventory the plan must exercise (paths relative to `/public-api/v1`):
|
||||
|
||||
- `GET /courses`, `POST /courses`
|
||||
- `GET /courses/:courseId/students`, `GET /courses/:courseId/export`, `GET /courses/:courseId/structure`, `PUT /courses/:courseId/structure`
|
||||
- `GET /courses/:courseId`, `PUT /courses/:courseId`, `DELETE /courses/:courseId`
|
||||
- `GET /audience`, `POST /audience`, `POST /audience/assign-courses`
|
||||
- `GET /audience/:memberId`, `PUT /audience/:memberId`, `DELETE /audience/:memberId`
|
||||
|
||||
Validation for these lives under [packages/utils/src/validation/public-api/](packages/utils/src/validation/public-api/) (`course.ts`, `audience.ts` only). If new routers are added under `v1/`, extend this inventory and add matching test files.
|
||||
|
||||
## Auth, API keys, and middleware enforcement
|
||||
|
||||
Public endpoints must never rely on “trust the client.” Tests must prove the following, aligned with [apps/api/src/middlewares/automation-key.ts](apps/api/src/middlewares/automation-key.ts) and [apps/api/src/middlewares/automation-key-scopes.ts](apps/api/src/middlewares/automation-key-scopes.ts).
|
||||
|
||||
**Bearer and API key validation (`automationKeyMiddleware`)**
|
||||
|
||||
- Missing `Authorization` → **401**, body `success: false`, stable `code` (e.g. `UNAUTHORIZED`).
|
||||
- Header present but not `Bearer <token>` → **401** (same shape).
|
||||
- Empty or whitespace-only token after `Bearer` → **401** (authenticate fails).
|
||||
- `authenticateOrganizationApiKeyService` throws (invalid / unknown key) → **401**, message surfaced from middleware catch path.
|
||||
- Successful auth → `c.set('orgId', …)`, `c.set('actorId', …)`, `c.set('automationKey', …)`; assert `touchOrganizationApiKeyLastUsedService` is invoked with the resolved key (proves real keys go through the “touch” path). Expiry is enforced inside `authenticateOrganizationApiKeyService` ([apps/api/src/services/organization/automation-key.ts](apps/api/src/services/organization/automation-key.ts)) — add a mock case where the service throws for an expired key and expect **401**.
|
||||
|
||||
**Scope enforcement (`automationKeyScopesMiddleware(['public_api:*'])`)**
|
||||
|
||||
- No `automationKey` in context (simulating a bug or bypass) → **401**.
|
||||
- Key present but `scopes` omit `public_api:*` (e.g. only MCP scopes) → **403**, body includes `code: FORBIDDEN` and a clear error string.
|
||||
- Key includes `public_api:*` → `next()` runs and the handler can execute.
|
||||
|
||||
**Full chain (no mocked middleware on the router)**
|
||||
|
||||
Route files that mock middleware cannot prove enforcement by themselves. Add **[apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts](apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts)**:
|
||||
|
||||
- Build a small Hono app that mounts a real v1 router (e.g. `v1CoursesRouter` under `/courses`) with **unmocked** `automationKeyMiddleware` and `automationKeyScopesMiddleware`.
|
||||
- Mock only `@api/services/organization/automation-key` (`authenticateOrganizationApiKeyService`, `touchOrganizationApiKeyLastUsedService`) and the v1 service module.
|
||||
- Cases: no `Authorization` → 401; `Bearer` + key that authenticates to a record **without** `public_api:*` → 403; `Bearer` + key with `public_api:*` → 200 and JSON matches the mocked service return (see next section).
|
||||
- Optionally repeat one path on `v1AudienceRouter` so audience is covered by the same guarantees.
|
||||
|
||||
**Service-level actor rules**
|
||||
|
||||
Several handlers require `actorId` from the key’s `createdByProfileId`. With middleware mocked to `actorId: null`, assert **401** and `AppError` / `handleError` shape for: `POST /courses`, `PUT /courses/:courseId/structure`, `POST /audience`, `PUT /audience/:memberId`. This documents that automation keys without a creator profile cannot perform those mutations.
|
||||
|
||||
## Response contracts (data must match expectations)
|
||||
|
||||
Do not only assert HTTP status. Parse `await response.json()` and validate **shape and values**.
|
||||
|
||||
**Success responses**
|
||||
|
||||
- Every happy path: `expect(body.success).toBe(true)`.
|
||||
- `GET/POST` course list/detail: `data` must **deep-equal** (or `toMatchObject` with explicit fields) the object/array returned by the mocked service — proves the handler forwards the service result, not an accidental empty object or wrong key.
|
||||
- `GET /audience`: assert `data`, `pagination`, and `query` exist and match the mocked `listAudienceService` return (same values the service produced).
|
||||
- `POST` create paths: assert **201** where applicable and `data` matches the created entity mock.
|
||||
- Where services return nested structures (course export / structure), assert at least stable top-level keys and one nested field so refactors do not silently change the contract.
|
||||
|
||||
**Error responses**
|
||||
|
||||
- Middleware errors: `success: false`, string `error`, string `code` — match actual payloads from middleware (401/403).
|
||||
- `handleError` / `AppError` paths: `success: false`, `error` message, `code` matching `ErrorCodes.*`, optional `field` when the route sets it. Assert status equals `AppError.statusCode` (404, 401, etc.).
|
||||
- Validation failures (Zod / hono-openapi): assert **4xx** and that the body documents validation failure in the form the stack returns (status + JSON); do not hand-wave as “400” without reading the response.
|
||||
|
||||
**Service unit tests**
|
||||
|
||||
- When a function maps or merges arguments (e.g. `createPublicApiCourseService` adds `organizationId`), assert the **exact** arguments passed into the downstream mock (`toHaveBeenCalledWith`).
|
||||
|
||||
## Files to Change
|
||||
|
||||
### 1. `[apps/api/vitest.config.ts](apps/api/vitest.config.ts)`
|
||||
|
||||
Add `resolve.alias` so Vitest resolves `@api/*` without needing `tsc-alias` at runtime:
|
||||
|
||||
```ts
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { alias: { '@api': resolve(__dirname, 'src') } },
|
||||
test: { globals: true, environment: 'node', ... }
|
||||
});
|
||||
```
|
||||
|
||||
(`@cio/*` packages already resolve from `node_modules` at test time.)
|
||||
|
||||
### 2. Route tests (mocked middleware — fast handler + validation coverage)
|
||||
|
||||
These files focus on **validators**, **handler wiring**, and **error mapping** with middleware stubbed so `orgId` / `actorId` are deterministic.
|
||||
|
||||
`**[apps/api/src/routes/v1/__tests__/courses.test.ts](apps/api/src/routes/v1/__tests__/courses.test.ts)**`
|
||||
|
||||
- `vi.mock` both middleware modules to inject `orgId`/`actorId` and call `next()`.
|
||||
- `vi.mock('@api/services/v1/course')` — all service functions as `vi.fn()`.
|
||||
- One `describe` block per endpoint. For **each** success case: set the mock return to a **concrete** object/array, perform the request, `const body = await res.json()`, then `expect(body).toEqual({ success: true, data: … })` (or `toMatchObject` only where the payload is huge and you document which fields are guaranteed).
|
||||
- Cases (plus response assertions as above):
|
||||
- `GET /` — 200; service throws `AppError` → assert status + `{ success: false, code, error }`
|
||||
- `POST /` — 201 + body matches mock; invalid body → validation status + body; middleware mock with `actorId` undefined/null → 401 + error contract
|
||||
- `GET /:courseId` — 200 + `data` equals `getCourseService` mock; non-UUID param → 4xx; `AppError` 404 → 404 + `COURSE_NOT_FOUND` (or whatever the service throws)
|
||||
- `PUT /:courseId`, `DELETE /:courseId` — same pattern
|
||||
- `GET …/students`, `export`, `structure` — 200 + `data` equals mock; structure route shares export service — assert same mock used if both tested
|
||||
- `PUT …/structure` — 200 + merged result shape; no `actorId` → 401; invalid JSON → validation error
|
||||
- Use `v1CoursesRouter.request('/path', { headers, body })`.
|
||||
|
||||
`**[apps/api/src/routes/v1/__tests__/audience.test.ts](apps/api/src/routes/v1/__tests__/audience.test.ts)**`
|
||||
|
||||
- Same middleware mocks as courses; mock `@api/services/v1/audience`.
|
||||
- Assert full list payload: `success`, `data`, `pagination`, `query` **equal** mocked `listAudienceService` output fields.
|
||||
- Other endpoints: same success/error JSON rules as courses.
|
||||
|
||||
### 2b. Auth chain tests (real middleware)
|
||||
|
||||
`**[apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts](apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts)**`
|
||||
|
||||
- Do **not** mock `automationKeyMiddleware` / `automationKeyScopesMiddleware`.
|
||||
- Mock `authenticateOrganizationApiKeyService` to return a full key-shaped object (`organizationId`, `createdByProfileId`, `scopes`, `id`, etc. — whatever the middleware reads).
|
||||
- Mount router under a prefix so paths match production (`/courses` + `GET /` → list).
|
||||
- Prove **401** without header, **403** when `scopes` is `[]` or MCP-only, **200** when `scopes` includes `public_api:*` and `data` matches mocked `listCoursesService` (or one audience list call).
|
||||
|
||||
### 3. Middleware tests
|
||||
|
||||
`**[apps/api/src/middlewares/__tests__/automation-key.test.ts](apps/api/src/middlewares/__tests__/automation-key.test.ts)**`
|
||||
|
||||
- `vi.mock('@api/services/organization/automation-key')` — mock `authenticateOrganizationApiKeyService` and `touchOrganizationApiKeyLastUsedService`.
|
||||
- Create a minimal Hono app that mounts the real `automationKeyMiddleware` + a `GET /` handler that returns context values.
|
||||
- Cases (each: `await res.json()` and assert `success`, `code`, `error` where applicable):
|
||||
- No `Authorization` header → 401 + `success: false` + `code: UNAUTHORIZED`
|
||||
- Header not `Bearer …` → 401 + same JSON shape
|
||||
- `authenticateOrganizationApiKeyService` throws (invalid or expired key; service throws before returning) → 401 + body matches middleware catch behavior
|
||||
- Valid key → 200; response reflects `orgId`, `actorId`, `automationKey` from context (echo in test handler)
|
||||
- `touchOrganizationApiKeyLastUsedService` called exactly once with the authenticated key
|
||||
|
||||
`**[apps/api/src/middlewares/__tests__/automation-key-scopes.test.ts](apps/api/src/middlewares/__tests__/automation-key-scopes.test.ts)**`
|
||||
|
||||
- No DB mocking needed; test `organizationApiKeyHasScopes` logic via the scope middleware itself.
|
||||
- Cases (parse JSON on 401/403):
|
||||
- `automationKey` not set in context → 401 + `success: false`
|
||||
- Key with `scopes: []` or without `public_api:*` → 403 + `code: FORBIDDEN` + error message string
|
||||
- Key with `public_api:*` → handler runs; response body proves `next()` was reached
|
||||
|
||||
### 4. Service unit tests
|
||||
|
||||
For every exported function, assert **return value** equals the expected object (or key fields) after the mocked dependency returns — not only “did not throw.” Use `expect(fn).toHaveBeenCalledWith(...)` for argument contracts.
|
||||
|
||||
`**[apps/api/src/services/v1/__tests__/course.test.ts](apps/api/src/services/v1/__tests__/course.test.ts)**`
|
||||
|
||||
Mock: `@cio/db/queries/tag` (`getCourseOrganizationId`), `@api/services/organization` (`getOrganizationCourses`), `@api/services/course/course` (`getCourse`, `updateCourse`, `deleteCourse`, `createCourse`), `@api/services/course/people` (`listCourseMembers`), course-import services.
|
||||
|
||||
- `assertCourseBelongsToOrg` helper: course not found → throws `AppError(404, COURSE_NOT_FOUND)`; org mismatch → same
|
||||
- `listCoursesService` — returns `items` from `getOrganizationCourses`
|
||||
- `getCourseService` — happy path; unauthorized org
|
||||
- `listCourseStudentsService` — filters by `ROLE.STUDENT`
|
||||
- `exportCourseService` — delegates to `getCourseImportStructureService`
|
||||
- `createPublicApiCourseService` — no `actorId` → 401; calls `createCourse` with `organizationId` merged in
|
||||
- `updatePublicApiCourseService` — calls `updateCourseService`
|
||||
- `deletePublicApiCourseService` — calls `deleteCourseService`
|
||||
- `updatePublicApiCourseStructureService` — no `actorId` → 401; creates draft then publishes
|
||||
|
||||
`**[apps/api/src/services/v1/__tests__/audience.test.ts](apps/api/src/services/v1/__tests__/audience.test.ts)**`
|
||||
|
||||
Mock: `@api/services/organization` (`getOrgAudience`, `getOrgAudienceMember`, `removeAudienceMember`), `@api/services/organization/audience` (`assignAudienceToCourses`, `importAudienceMembers`, `updatePendingAudienceMemberEmail`).
|
||||
|
||||
- `listAudienceService` — delegates to `getOrgAudience`
|
||||
- `createAudienceService` — no `actorId` → 401; maps payload fields correctly
|
||||
- `getAudienceMemberService` — passes through
|
||||
- `assignAudienceCoursesService` — passes through
|
||||
- `removeAudienceService` — passes through; `actorId` null defaults to `undefined`
|
||||
- `updateAudienceService` — no `actorId` → 401; calls update then re-fetches member
|
||||
|
||||
## Total: 8 new files, 1 updated file
|
||||
|
||||
@@ -92,6 +92,10 @@ OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=http://localhost:9000/media
|
||||
GOOGLE_CLIENT_ID=
|
||||
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.
|
||||
# Without this, auth cookies may not be set and login will fail.
|
||||
AUTH_COOKIE_DOMAIN=
|
||||
|
||||
@@ -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 }}
|
||||
@@ -24,6 +24,7 @@ Use this file to keep a single prioritized view of all PRDs.
|
||||
| - | 10 | `prd/pricing-improvements` | todo |
|
||||
| - | 11 | `prd/public-api` | todo |
|
||||
| - | 12 | `prd/public-courses` | todo |
|
||||
| - | - | `prd/customer-course-documentation` | todo |
|
||||
| - | 15 | `prd/webhooks` | todo |
|
||||
|
||||
## DONE (Completed PRDs)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -38,10 +38,11 @@ CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||
The script will:
|
||||
|
||||
1. **Generate** the OpenAPI specification from your Hono app routes
|
||||
2. **Save locally** to `dist/openapi/openapi.json`
|
||||
3. **Upload to R2** (if credentials provided):
|
||||
- `openapi/openapi-YYYY-MM-DD.json` (dated version)
|
||||
- `openapi/openapi-latest.json` (latest version)
|
||||
2. **Filter** the output to `/public-api/v1/*`
|
||||
3. **Save locally** to `dist/openapi/public-api/openapi.json`
|
||||
4. **Upload to R2** (if credentials provided):
|
||||
- `openapi/public-api/openapi-YYYY-MM-DD.json` (dated version)
|
||||
- `openapi/public-api/openapi-latest.json` (latest version)
|
||||
|
||||
## GitHub Action Integration
|
||||
|
||||
|
||||
@@ -16,6 +16,35 @@ interface UploadOptions {
|
||||
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 {
|
||||
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');
|
||||
|
||||
return specString;
|
||||
@@ -80,7 +110,7 @@ class OpenAPISpecGenerator {
|
||||
|
||||
async saveLocalSpec(spec: string): Promise<string> {
|
||||
try {
|
||||
const outputDir = join(process.cwd(), 'dist', 'openapi');
|
||||
const outputDir = join(process.cwd(), 'dist', 'openapi', 'public-api');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const filePath = join(outputDir, 'openapi.json');
|
||||
@@ -122,10 +152,10 @@ class OpenAPISpecGenerator {
|
||||
try {
|
||||
const spec = await this.generateSpec();
|
||||
|
||||
// await this.saveLocalSpec(spec);
|
||||
await this.saveLocalSpec(spec);
|
||||
|
||||
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({
|
||||
key: uploadKey,
|
||||
content: spec,
|
||||
@@ -134,7 +164,7 @@ class OpenAPISpecGenerator {
|
||||
|
||||
// Also upload as latest version
|
||||
await this.uploadToR2({
|
||||
key: 'openapi/openapi-latest.json',
|
||||
key: 'openapi/public-api/openapi-latest.json',
|
||||
content: spec,
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
@@ -20,11 +20,13 @@ import { organizationRouter } from '@api/routes/organization';
|
||||
import { licenseRouter } from '@api/routes/license';
|
||||
import { organizationSsoRouter } from '@api/routes/organization/sso';
|
||||
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
|
||||
import { v1Router } from '@api/routes/v1';
|
||||
import { ssoDiscoveryRouter } from '@api/routes/sso/discovery';
|
||||
import { prettyJSON } from 'hono/pretty-json';
|
||||
import rateLimiter from '@api/middlewares/rate-limiter';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
import { signupGuard } from '@api/middlewares/signup-guard';
|
||||
import { programRouter } from '@api/routes/program';
|
||||
import { unsplashRouter } from '@api/routes/unsplash/unsplash';
|
||||
|
||||
// Create Hono app with chaining for RPC support
|
||||
@@ -94,6 +96,8 @@ export const app = new Hono()
|
||||
.route('/dash', dashAnalyticsRouter)
|
||||
.route('/community', communityRouter)
|
||||
.route('/invite', inviteRouter)
|
||||
.route('/public-api/v1', v1Router)
|
||||
.route('/program', programRouter)
|
||||
.route('/unsplash', unsplashRouter)
|
||||
|
||||
// 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 { 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
|
||||
@@ -39,6 +40,11 @@ export const courseMemberMiddleware = async (c: Context, next: Next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
const backfilledFromProgram = await ensureProgramCourseAccess(courseId, user.id);
|
||||
if (backfilledFromProgram) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -88,8 +88,5 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
|
||||
|
||||
// Default rate limiter middleware with standard configuration
|
||||
export default createRateLimiter({
|
||||
maxRequests: (c) =>
|
||||
c.req.path === '/api/auth/get-session'
|
||||
? DEFAULT_RATE_LIMITER_OPTIONS.maxRequests * 3
|
||||
: DEFAULT_RATE_LIMITER_OPTIONS.maxRequests
|
||||
maxRequests: (c) => (c.req.path === '/api/auth/get-session' ? 1000 : DEFAULT_RATE_LIMITER_OPTIONS.maxRequests)
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ZCourseInviteTokenParam } from '@cio/utils/validation/course/invite';
|
||||
import { previewStudentInvite } from '@api/services/course/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 { apiKeyMiddleware } from '@api/middlewares/api-key';
|
||||
@@ -10,6 +15,7 @@ import { createRateLimiter } from '@api/middlewares/rate-limiter';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { extractClientIp } from '@api/utils/redis/key-generators';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import * as z from 'zod';
|
||||
|
||||
const previewInviteRateLimit = createRateLimiter({
|
||||
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()
|
||||
/**
|
||||
* 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
|
||||
* 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 userRole = c.get('userRole');
|
||||
const automationKey = c.get('automationKey');
|
||||
const { tags } = c.req.valid('query');
|
||||
const query = c.req.valid('query');
|
||||
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
@@ -403,25 +403,28 @@ export const organizationRouter = new Hono()
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
|
||||
}
|
||||
|
||||
const tagSlugs = tags
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const result = await getOrganizationCourses(
|
||||
orgId,
|
||||
user?.id ?? automationKey?.createdByProfileId ?? '',
|
||||
userRole ?? ROLE.ADMIN,
|
||||
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined
|
||||
query
|
||||
);
|
||||
|
||||
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(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
data: result.items,
|
||||
pagination: result.pagination,
|
||||
query: result.query
|
||||
},
|
||||
200
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { programRouter } from './program';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
@@ -45,7 +45,7 @@ export async function fetchCommunityQuestions(
|
||||
case ROLE.TUTOR:
|
||||
// Tutors can see questions in courses they have access to
|
||||
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 });
|
||||
break;
|
||||
case ROLE.STUDENT:
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
updateCourseSectionService
|
||||
} from '@api/services/course/section';
|
||||
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 {
|
||||
createCourseImportDraft,
|
||||
getCourseImportDraftById,
|
||||
@@ -76,6 +80,10 @@ type PublishDraftToExistingCourseResult = PublishDraftResult & {
|
||||
updatedExercises: number;
|
||||
createdLessonLanguages: number;
|
||||
updatedLessonLanguages: number;
|
||||
deletedSections: number;
|
||||
deletedLessons: number;
|
||||
deletedExercises: number;
|
||||
deletedLessonLanguages: number;
|
||||
untouchedSections: number;
|
||||
untouchedLessons: number;
|
||||
untouchedExercises: number;
|
||||
@@ -579,7 +587,7 @@ export async function createCourseImportDraftFromCourseService(
|
||||
|
||||
export async function getCourseImportStructureService(orgId: string, courseId: string) {
|
||||
try {
|
||||
return await buildCourseStructureSnapshot(orgId, courseId);
|
||||
return buildCourseStructureSnapshot(orgId, courseId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
@@ -863,6 +871,7 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
const existingDraft = await getCourseImportDraftService(orgId, draftId);
|
||||
const draft = parseStoredDraft(existingDraft);
|
||||
const course = await getCourseRecord(orgId, overrides.courseId);
|
||||
const syncMode = overrides.syncMode ?? 'merge';
|
||||
const courseTitle = overrides.title ?? draft.course.title;
|
||||
const courseDescription = overrides.description ?? draft.course.description;
|
||||
const courseType = overrides.type ?? draft.course.type;
|
||||
@@ -1003,10 +1012,10 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
}
|
||||
|
||||
const appliedTagNames = normalizeDraftTagNames(draft.tags);
|
||||
if (appliedTagNames.length > 0) {
|
||||
if (appliedTagNames.length > 0 || syncMode === 'replace') {
|
||||
await assignCourseTagsByName(orgId, course.id, {
|
||||
tagNames: appliedTagNames,
|
||||
mode: 'merge'
|
||||
mode: syncMode
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1015,6 +1024,72 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
const referencedExerciseIds = new Set(
|
||||
(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);
|
||||
|
||||
await updateCourseImportDraft(orgId, draftId, {
|
||||
@@ -1026,8 +1101,14 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
publishedCourseId: course.id,
|
||||
updatedSections,
|
||||
updatedLessons,
|
||||
updatedExercises,
|
||||
createdLessonLanguages,
|
||||
updatedLessonLanguages
|
||||
updatedLessonLanguages,
|
||||
deletedSections,
|
||||
deletedLessons,
|
||||
deletedExercises,
|
||||
deletedLessonLanguages,
|
||||
syncMode
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1044,9 +1125,13 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
updatedExercises,
|
||||
createdLessonLanguages,
|
||||
updatedLessonLanguages,
|
||||
untouchedSections: existingSections.length - referencedSectionIds.size,
|
||||
untouchedLessons: existingLessons.length - referencedLessonIds.size,
|
||||
untouchedExercises: existingExercises.length - referencedExerciseIds.size,
|
||||
deletedSections,
|
||||
deletedLessons,
|
||||
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
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
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 {
|
||||
createCourseNewsfeed,
|
||||
createCourse as createCourseQuery,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
@@ -29,6 +30,45 @@ import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type Co
|
||||
const DEFAULT_CONTENT_GROUPING = true;
|
||||
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) {
|
||||
if (!metadata) return metadata;
|
||||
|
||||
@@ -133,49 +173,57 @@ export async function createCourse(
|
||||
try {
|
||||
const description = sanitizeHtml(data.description);
|
||||
|
||||
const result = await db.transaction(async () => {
|
||||
// 1. Create group
|
||||
const [newGroup] = await createGroup({
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [newGroup] = await createGroup(
|
||||
{
|
||||
name: data.title,
|
||||
description,
|
||||
organizationId: data.organizationId
|
||||
});
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newGroup) {
|
||||
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,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id
|
||||
});
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newCourse) {
|
||||
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,
|
||||
groupId: newGroup.id,
|
||||
roleId: ROLE.TUTOR
|
||||
});
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newMember) {
|
||||
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 🎉 </h2>
|
||||
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
|
||||
courseId: newCourse.id,
|
||||
isPinned: true,
|
||||
authorId: newMember.id
|
||||
});
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return {
|
||||
course: newCourse,
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
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 { getOrgIdBySiteName } from '@cio/db/queries';
|
||||
|
||||
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
|
||||
const analytics: OrganisationAnalytics = {
|
||||
revenue: 0,
|
||||
totalCertificates: 0,
|
||||
numberOfCourses: 0,
|
||||
totalStudents: 0,
|
||||
topCourses: [],
|
||||
enrollments: []
|
||||
recentCertifications: []
|
||||
};
|
||||
|
||||
let resolvedOrgId = orgId;
|
||||
@@ -26,13 +31,14 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
|
||||
}
|
||||
|
||||
try {
|
||||
const [stats, topCourses, enrollments] = await Promise.all([
|
||||
const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([
|
||||
getDashOrgStats(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.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
|
||||
|
||||
@@ -40,16 +46,17 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
|
||||
id: c.courseId,
|
||||
title: c.courseTitle,
|
||||
enrollments: c.totalStudents,
|
||||
completion: c.completionPercentage
|
||||
completion: c.completionPercentage,
|
||||
certification: c.certificationPercentage
|
||||
}));
|
||||
|
||||
analytics.enrollments = enrollments.map((e) => ({
|
||||
id: e.profileId,
|
||||
avatarUrl: e.avatarUrl,
|
||||
name: e.fullname,
|
||||
courseId: e.courseId,
|
||||
course: e.courseTitle,
|
||||
date: e.enrolledAt ?? ''
|
||||
analytics.recentCertifications = recentCertificationRows.map((row) => ({
|
||||
id: row.profileId,
|
||||
avatarUrl: row.avatarUrl,
|
||||
name: row.fullname,
|
||||
courseId: row.courseId,
|
||||
course: row.courseTitle,
|
||||
date: row.earnedAt ?? ''
|
||||
}));
|
||||
|
||||
return analytics;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
updateOptions,
|
||||
updateQuestions
|
||||
} from '@cio/db/queries/exercise';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
|
||||
import { db } 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
|
||||
return await getExercise(exercise.id);
|
||||
const result = await getExercise(exercise.id);
|
||||
|
||||
if (sanitizedData.courseId) {
|
||||
await touchCourseUpdatedAt(sanitizedData.courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('createExercise error:', error);
|
||||
if (error instanceof AppError) {
|
||||
@@ -193,7 +200,9 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
|
||||
try {
|
||||
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;
|
||||
|
||||
// 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 await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('updateExerciseService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
@@ -270,7 +286,9 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
|
||||
try {
|
||||
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 exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
|
||||
@@ -303,6 +321,13 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
|
||||
|
||||
return await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('replaceExerciseService error:', error);
|
||||
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);
|
||||
}
|
||||
|
||||
if (exercise.courseId) {
|
||||
await touchCourseUpdatedAt(exercise.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
|
||||
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
|
||||
import {
|
||||
deleteLessonLanguage,
|
||||
getLessonLanguageByLessonIdAndLocale,
|
||||
getLessonLanguagesByLessonId,
|
||||
updateLessonLanguage,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from '@cio/db/queries/lesson/language';
|
||||
|
||||
import { getLessonById } from '@cio/db/queries/lesson/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
|
||||
/**
|
||||
* Gets all language translations for a lesson
|
||||
@@ -70,6 +72,10 @@ export async function upsertLessonLanguageService(
|
||||
lessonId
|
||||
});
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return language;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -111,6 +117,11 @@ export async function updateLessonLanguageService(
|
||||
...data,
|
||||
content: sanitizeOptionalHtml(data.content)
|
||||
});
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return language;
|
||||
} catch (error) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
upsertLessonCompletion,
|
||||
type LessonById
|
||||
} from '@cio/db/queries/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
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);
|
||||
}
|
||||
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
|
||||
return lesson;
|
||||
} catch (error) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (updated.courseId) {
|
||||
await touchCourseUpdatedAt(updated.courseId);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (deleted.courseId) {
|
||||
await touchCourseUpdatedAt(deleted.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -168,7 +179,14 @@ export async function reorderLessons(lessons: TLessonReorder['lessons']): Promis
|
||||
);
|
||||
|
||||
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) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder lessons',
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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 {
|
||||
deleteOrganizationAudienceMember,
|
||||
cancelOrganizationPlan,
|
||||
createOrganizationPlan,
|
||||
deleteOrganizationAudienceMember,
|
||||
deleteOrganizationMember,
|
||||
getOrgIdBySiteName,
|
||||
getActiveOrganizationPlan,
|
||||
getFirstOrganizationWithPlans,
|
||||
getLatestOrgInvitesByEmails,
|
||||
getOrgIdBySiteName,
|
||||
getOrganizationAudience,
|
||||
getOrganizationAudienceMember,
|
||||
getOrganizationBySiteName,
|
||||
getOrganizationTeam,
|
||||
getOrganizations,
|
||||
@@ -16,8 +20,8 @@ import {
|
||||
updateOrganization,
|
||||
updateOrganizationPlan
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
|
||||
import {
|
||||
countPublishedCoursesBySiteName,
|
||||
getCoursesById,
|
||||
getCoursesBySiteNameForSetup,
|
||||
getEnrolledCourses,
|
||||
@@ -27,16 +31,18 @@ import {
|
||||
getOrgCourses,
|
||||
getPublishedCoursesBySiteName
|
||||
} from '@cio/db/queries/course';
|
||||
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
|
||||
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
|
||||
|
||||
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { createOrganizationWithOwner } from '@api/services/onboarding';
|
||||
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
|
||||
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
|
||||
import type { TGetAudienceQuery } from '@cio/utils/validation/organization';
|
||||
|
||||
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param orgId - The organization ID
|
||||
@@ -218,20 +259,31 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
|
||||
if (tagSlugs && tagSlugs.length > 0) {
|
||||
filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs);
|
||||
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(
|
||||
org.id,
|
||||
courses.map((course) => course.id)
|
||||
);
|
||||
|
||||
return courses.map((course) => ({
|
||||
return {
|
||||
courses: courses.map((course) => ({
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
}));
|
||||
})),
|
||||
hasMoreCourses: totalCourses > courses.length
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
@@ -251,44 +303,110 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
|
||||
* - Admins: all 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 {
|
||||
if (!userRole) {
|
||||
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;
|
||||
if (tagSlugs && tagSlugs.length > 0) {
|
||||
filteredCourseIds = await getCourseIdsByTagSlugs(orgId, tagSlugs);
|
||||
if (filteredCourseIds.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
items: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
},
|
||||
query: {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
tags: query.tags
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
switch (userRole) {
|
||||
case ROLE.ADMIN: {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: filteredCourseIds });
|
||||
const courses = await getOrgCourses({
|
||||
orgId,
|
||||
courseIds: filteredCourseIds,
|
||||
page,
|
||||
limit,
|
||||
search
|
||||
});
|
||||
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||
orgId,
|
||||
courses.map((course) => course.id)
|
||||
courses.items.map((course) => course.id)
|
||||
);
|
||||
|
||||
return courses.map((course) => ({
|
||||
return {
|
||||
items: courses.items.map((course) => ({
|
||||
...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: {
|
||||
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(
|
||||
orgId,
|
||||
courses.map((course) => course.id)
|
||||
courses.items.map((course) => course.id)
|
||||
);
|
||||
|
||||
return courses.map((course) => ({
|
||||
return {
|
||||
items: courses.items.map((course) => ({
|
||||
...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:
|
||||
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>) {
|
||||
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);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
|
||||
@@ -6,11 +6,13 @@ import type {
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
|
||||
import {
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudits,
|
||||
createOrganizationInvites,
|
||||
createOrganizationMembers,
|
||||
getOrganizationAudienceMember,
|
||||
getLatestOrganizationInviteRowByOrgAndEmail,
|
||||
getOrgMembersByProfileIds,
|
||||
getOrganizationById,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
|
||||
import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
@@ -113,16 +116,33 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud
|
||||
let courseNames: string[] = [];
|
||||
if (data.allCourses) {
|
||||
const courses = await getOrgCourses({ orgId });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
courseIds = courses.items.map((c) => c.id);
|
||||
courseNames = courses.items.map((c) => c.title).filter(Boolean);
|
||||
} else if (data.courseIds && data.courseIds.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
courseIds = courses.items.map((c) => c.id);
|
||||
courseNames = courses.items.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
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(
|
||||
orgId: string,
|
||||
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: {
|
||||
orgId: string;
|
||||
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
|
||||
emails: string[];
|
||||
courseIds: string[];
|
||||
courseNamesLabel: string | undefined;
|
||||
programIds: string[];
|
||||
accessNamesLabel: string | undefined;
|
||||
invitedByProfileId: string;
|
||||
shouldSendEmail: boolean;
|
||||
}): 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) {
|
||||
return { created: 0, emailsSent: 0, emailsFailed: 0 };
|
||||
@@ -236,7 +338,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
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,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
courseIds,
|
||||
programIds
|
||||
}
|
||||
};
|
||||
})
|
||||
@@ -283,7 +387,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
courseNames: accessNamesLabel
|
||||
},
|
||||
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 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,
|
||||
organization,
|
||||
existingStudentProfileIds,
|
||||
courseIds,
|
||||
data.sendEmail
|
||||
);
|
||||
const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
|
||||
organization,
|
||||
orgId,
|
||||
existingStudentProfileIds,
|
||||
programIds,
|
||||
data.sendEmail
|
||||
);
|
||||
|
||||
let imported = 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({
|
||||
orgId,
|
||||
organization,
|
||||
emails: newEmails,
|
||||
courseIds,
|
||||
courseNamesLabel,
|
||||
programIds,
|
||||
accessNamesLabel,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
});
|
||||
@@ -447,7 +574,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
organization,
|
||||
emails: pendingStudentEmails,
|
||||
courseIds,
|
||||
courseNamesLabel,
|
||||
programIds,
|
||||
accessNamesLabel,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
});
|
||||
@@ -457,11 +585,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
|
||||
return {
|
||||
imported,
|
||||
assigned: assignResult.assigned,
|
||||
alreadyEnrolledInCourses: assignResult.alreadyEnrolled,
|
||||
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
|
||||
alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
|
||||
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
|
||||
pendingInvitesRenewed: pendingStudentEmails.length,
|
||||
duplicates: recipients.duplicates.length,
|
||||
emailsSent: assignResult.emailsSent + importEmailsSent + pendingEmailsSent,
|
||||
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
|
||||
emailsFailed: importEmailsFailed + pendingEmailsFailed
|
||||
};
|
||||
}
|
||||
@@ -483,22 +612,32 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
const emailToUse = member.email.toLowerCase().trim();
|
||||
|
||||
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
|
||||
const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined;
|
||||
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? [];
|
||||
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
|
||||
const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? [];
|
||||
const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? [];
|
||||
|
||||
let courseIds: string[] = [];
|
||||
let courseNames: string[] = [];
|
||||
if (courseIdsFromMetadata.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
courseIds = courses.items.map((c) => c.id);
|
||||
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);
|
||||
|
||||
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
||||
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({
|
||||
organizationId: orgId,
|
||||
@@ -510,7 +649,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
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,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
courseIds,
|
||||
programIds
|
||||
}
|
||||
}
|
||||
]);
|
||||
@@ -542,7 +683,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
courseNames: accessNamesLabel
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
|
||||
const courseIds = data.courseIds ?? [];
|
||||
const programIds = data.programIds ?? [];
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
return enrollAudienceStudentProfilesInCourses(orgId, organization, data.profileIds, data.courseIds, data.sendEmail);
|
||||
assignedToCourses = await enrollAudienceStudentProfilesInCourses(
|
||||
orgId,
|
||||
organization,
|
||||
data.profileIds,
|
||||
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,
|
||||
type TMcpToolName
|
||||
} from '@cio/utils/plans';
|
||||
import { PLAN } from '@cio/utils/plans/constants';
|
||||
|
||||
export type OrganizationAutomationUsageSummary = {
|
||||
type: TOrganizationApiKeyType;
|
||||
@@ -56,11 +57,16 @@ export async function assertOrganizationAutomationKeyCreationAllowed(
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType
|
||||
): 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const planName = await getOrganizationPlanName(organizationId);
|
||||
const limits = getMcpAutomationLimits(planName);
|
||||
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
|
||||
|
||||
|
||||
@@ -7,18 +7,20 @@ import {
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudit,
|
||||
createOrganizationMembers,
|
||||
getActivePendingOrgInviteForEmail,
|
||||
getOrganizationById,
|
||||
getOrganizationInviteByTokenHash,
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
||||
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
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 { sendEmail } from '@cio/email';
|
||||
|
||||
@@ -426,6 +428,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
if (invite) {
|
||||
const metadata = invite.invite.metadata;
|
||||
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
||||
const programIds = parseProgramIdsFromInviteMetadata(metadata);
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
@@ -441,6 +444,30 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
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';
|
||||
@@ -452,3 +479,210 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,10 @@ export interface TopCourse {
|
||||
title: string;
|
||||
enrollments: number;
|
||||
completion: number;
|
||||
certification: number;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
export interface RecentCertification {
|
||||
id: string;
|
||||
avatarUrl: string | null;
|
||||
name: string;
|
||||
@@ -15,9 +16,9 @@ export interface Enrollment {
|
||||
}
|
||||
|
||||
export interface OrganisationAnalytics {
|
||||
revenue: number;
|
||||
totalCertificates: number;
|
||||
numberOfCourses: number;
|
||||
totalStudents: number;
|
||||
topCourses: TopCourse[];
|
||||
enrollments: Enrollment[];
|
||||
recentCertifications: RecentCertification[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
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
|
||||
*/
|
||||
@@ -26,146 +30,6 @@ export interface ErrorResponse {
|
||||
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
|
||||
* @param c - Hono context
|
||||
|
||||
@@ -11,6 +11,7 @@ type LessonDocument = NonNullable<TLesson['documents']>[number];
|
||||
|
||||
function mapProviderToVideoType(provider: string): LessonVideo['type'] {
|
||||
if (provider === 'youtube') return 'youtube';
|
||||
if (provider === 'google_drive') return 'google_drive';
|
||||
if (provider === 'generic' || provider === 'external_url') return 'generic';
|
||||
return 'upload';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
|
||||
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
function parseInviteIds(metadata: unknown, keys: string[]): string[] {
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
if (metadata == null) {
|
||||
return [];
|
||||
@@ -17,9 +16,21 @@ export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
if (!obj) {
|
||||
return [];
|
||||
}
|
||||
const raw = obj.courseIds ?? obj.course_ids;
|
||||
|
||||
const raw = keys.map((key) => obj[key]).find((value) => value !== undefined);
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,17 @@
|
||||
* These functions help create consistent and secure rate limit keys
|
||||
*/
|
||||
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
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
|
||||
*/
|
||||
@@ -15,9 +24,11 @@ export const extractClientIp = (c: Context): string => {
|
||||
// 3. X-Forwarded-For (standard, but can be spoofed)
|
||||
// 4. Connection IP (least reliable)
|
||||
|
||||
const cfConnectingIp = c.req.header('cf-connecting-ip');
|
||||
const realIp = c.req.header('x-real-ip');
|
||||
const cfConnectingIp = normalizeIp(c.req.header('cf-connecting-ip'));
|
||||
const realIp = normalizeIp(c.req.header('x-real-ip'));
|
||||
const forwardedFor = c.req.header('x-forwarded-for');
|
||||
const connInfo = getConnInfo(c);
|
||||
const remoteAddress = normalizeIp(connInfo.remote.address);
|
||||
|
||||
if (cfConnectingIp) {
|
||||
return cfConnectingIp;
|
||||
@@ -30,7 +41,11 @@ export const extractClientIp = (c: Context): string => {
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
|
||||
// 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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
|
||||
@@ -19,26 +19,26 @@
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^0.21.16",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"bits-ui": "^2.16.1",
|
||||
"clsx": "^2.1.1",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "^0.3.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@cio/ui": "workspace:*",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"lucide-svelte": "^0.468.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"posthog-js": "^1.180.1",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
@import '@cio/ui/output.css';
|
||||
@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-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> </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">
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { ShimmerButton } from '$lib/components/animation/shimmerbutton';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { buttonVariants } from '@cio/ui/base/button';
|
||||
import { ShimmerButton } from '@cio/ui/custom/animation';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { cn } from '$lib/utils';
|
||||
import BadgeCheck from 'lucide-svelte/icons/badge-check';
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
</script>
|
||||
|
||||
<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}`)}>
|
||||
{ctaLabel}
|
||||
</Dialog.Trigger>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 { cn } from '$lib/utils';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
brought to you by
|
||||
@@ -35,7 +35,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
</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">
|
||||
<a href="/" class="mr-6 flex items-center">
|
||||
<img src="/logo-512.png" alt="Course App" class="h-8 w-8" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Santa from './santa.svg';
|
||||
import { BorderBeam } from '$lib/components/animation/border-beam';
|
||||
import { DotPattern } from '$lib/components/animation/dot-pattern';
|
||||
import { BorderBeam, DotPattern } from '@cio/ui/custom/animation';
|
||||
import { BuyTemplate } from '$lib/components/buy-template';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<SeparatorPrimitive.Root
|
||||
class={cn(
|
||||
"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
|
||||
)}
|
||||
{orientation}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={$mode}
|
||||
theme={mode.current}
|
||||
class="toaster group"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<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 { TemplateCard, CustomTemplate } from '$lib/components/template-card';
|
||||
import { Meteors } from '$lib/components/animation/meteors';
|
||||
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 { Sparkle } from '$lib/components/animation/sparkle';
|
||||
import { HeroVideoDialog } from '$lib/components/animation/hero-video-dialog';
|
||||
import {
|
||||
BlurFade,
|
||||
BlurIn,
|
||||
BorderBeam,
|
||||
CardBody,
|
||||
CardContainer,
|
||||
HeroVideoDialog,
|
||||
Meteors,
|
||||
ShimmerButton,
|
||||
Sparkle
|
||||
} from '@cio/ui/custom/animation';
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<span>Site for your Product in</span>
|
||||
@@ -157,7 +160,7 @@
|
||||
|
||||
<!-- Templates -->
|
||||
<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">
|
||||
Browse Templates
|
||||
</h2>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess({
|
||||
script: true
|
||||
}),
|
||||
|
||||
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(),
|
||||
|
||||
alias: {
|
||||
$lib: './src/lib',
|
||||
$lib: './src/lib/*'
|
||||
$lib: path.resolve(__dirname, './src/lib'),
|
||||
'@cio/ui': path.resolve(__dirname, './node_modules/@cio/ui/src'),
|
||||
'@cio/ui/*': path.resolve(__dirname, './node_modules/@cio/ui/src/*')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -20,6 +20,10 @@ PUBLIC_IS_SELFHOSTED=false
|
||||
# CSP_FONT_SRC_DOMAINS=
|
||||
# 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_API_KEY=
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"test": "jest",
|
||||
"test:watch": "pnpm run test --watch",
|
||||
"ci": "cp .env.example .env && vite build && vite preview",
|
||||
"script:translate": "node ./scripts/translate.cjs"
|
||||
"translate": "node ./scripts/translate.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/sso": "1.4.18",
|
||||
|
||||
@@ -9,6 +9,7 @@ const jessy = require('jessy');
|
||||
const set = require('lodash/set');
|
||||
|
||||
const englishTranslations = require('../src/lib/utils/translations/en.json');
|
||||
let prettierPromise;
|
||||
|
||||
// Load env variables
|
||||
dotenv.config();
|
||||
@@ -38,6 +39,25 @@ const selectedLanguageFiles = requestedLanguages.length
|
||||
|
||||
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 englishKeys = keys(englishTranslations);
|
||||
const targetTranslations = require(outputFilePath); // Load target language translations
|
||||
@@ -109,7 +129,7 @@ const translateLanguage = async (fromLanguage, toLanguage, outputFilePath) => {
|
||||
set(targetTranslations, key, value);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputFilePath, JSON.stringify(targetTranslations, null, 2));
|
||||
await writeFormattedTranslationFile(outputFilePath, targetTranslations);
|
||||
console.log(`${toLanguage.toUpperCase()} translations updated successfully.`);
|
||||
} else {
|
||||
console.log(`Failed to update ${toLanguage.toUpperCase()} translations.`);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
@import '@cio/ui/output.css';
|
||||
|
||||
@source '../../packages/ui/src/custom/animation/**/*.svelte';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Geist variable font — self-hosted */
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import type { OrganizationAudienceSortBy, OrganizationAudienceSortOrder } from '$features/org/utils/types';
|
||||
|
||||
interface Props {
|
||||
hasSelection: boolean;
|
||||
selectedCount: number;
|
||||
searchValue?: string;
|
||||
sortValue: string;
|
||||
onSortChange: (value: string) => void;
|
||||
sortBy: OrganizationAudienceSortBy;
|
||||
sortOrder: OrganizationAudienceSortOrder;
|
||||
onSortChange: (sortBy: OrganizationAudienceSortBy, sortOrder: OrganizationAudienceSortOrder) => void;
|
||||
onOpenAssign: () => void;
|
||||
}
|
||||
|
||||
@@ -18,19 +20,37 @@
|
||||
hasSelection,
|
||||
selectedCount,
|
||||
searchValue = $bindable(''),
|
||||
sortValue,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onOpenAssign
|
||||
}: Props = $props();
|
||||
|
||||
const sortOptions = [
|
||||
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
|
||||
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
|
||||
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
|
||||
{ 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' }
|
||||
{ label: t.get('audience.date_joined'), value: 'createdAt' },
|
||||
{ label: t.get('audience.name'), value: 'name' },
|
||||
{ label: t.get('audience.email'), value: 'email' }
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<Page.BodyHeader>
|
||||
@@ -46,20 +66,16 @@
|
||||
{:else}
|
||||
<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" />
|
||||
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
|
||||
<Select.Trigger class="w-full md:w-[220px]">
|
||||
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<SortPopover
|
||||
{sortOptions}
|
||||
bind:sortKey={localSortKey}
|
||||
bind:selectedOrder={localSortOrder}
|
||||
defaultSortKey="createdAt"
|
||||
defaultSortOrder="desc"
|
||||
onSortKeyChange={handleSortKeyChange}
|
||||
onOrderChange={handleOrderChange}
|
||||
onClearFilters={handleClearSort}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
|
||||
let deleteDialogOpen = $state(false);
|
||||
let searchValue = $state(query.search ?? '');
|
||||
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
|
||||
|
||||
async function handleResendInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
@@ -126,7 +125,6 @@
|
||||
|
||||
$effect(() => {
|
||||
searchValue = query.search ?? '';
|
||||
sortValue = `${query.sortBy}:${query.sortOrder}`;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -179,23 +177,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function handleSortChange(nextSortValue: string) {
|
||||
if (nextSortValue === sortValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortValue = nextSortValue;
|
||||
const [sortBy, sortOrder] = nextSortValue.split(':') as [
|
||||
OrganizationAudienceQuery['sortBy'],
|
||||
OrganizationAudienceQuery['sortOrder']
|
||||
];
|
||||
|
||||
void navigateAudience({
|
||||
...query,
|
||||
page: 1,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
function handleSortChange(
|
||||
sortBy: OrganizationAudienceQuery['sortBy'],
|
||||
sortOrder: OrganizationAudienceQuery['sortOrder']
|
||||
) {
|
||||
void navigateAudience({ ...query, page: 1, sortBy, sortOrder });
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(member: OrganizationAudienceMember) {
|
||||
@@ -236,7 +222,8 @@
|
||||
{hasSelection}
|
||||
selectedCount={selectedIds.size}
|
||||
bind:searchValue
|
||||
{sortValue}
|
||||
sortBy={query.sortBy}
|
||||
sortOrder={query.sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onOpenAssign={() => (assignModalOpen = true)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
@@ -17,15 +16,23 @@
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
interface Program {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let { courses }: Props = $props();
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
programs: Program[];
|
||||
}
|
||||
|
||||
let { courses, programs }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let programAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let selectedProgramIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
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$/, ''));
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
@@ -51,7 +68,9 @@
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
allPrograms: programAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
@@ -66,7 +85,7 @@
|
||||
}
|
||||
</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>
|
||||
|
||||
<TextareaField
|
||||
@@ -108,11 +127,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
|
||||
{$t('audience.import.submit')}
|
||||
</Button>
|
||||
{#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>
|
||||
<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>
|
||||
|
||||
{#if programAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
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 { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -60,16 +58,12 @@
|
||||
</script>
|
||||
|
||||
{#if userAnalytics}
|
||||
<div class="p-5">
|
||||
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-1">
|
||||
<HeroProfileCard user={userAnalytics!.user} />
|
||||
|
||||
<div class="mt-5 px-0">
|
||||
<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} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -114,7 +108,6 @@
|
||||
</div>
|
||||
|
||||
{#each filteredCourses as course (course.id)}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
course.lessons_count === course.lessons_completed
|
||||
@@ -161,7 +154,6 @@
|
||||
<p>{course.progress_percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,8 @@ class AutomationApi extends BaseApiWithErrors {
|
||||
onSuccess: (response) => {
|
||||
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
|
||||
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 };
|
||||
}
|
||||
snackbar.success('snackbar.automation.revoked');
|
||||
|
||||
@@ -1,14 +1,221 @@
|
||||
<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 { snackbar } from '$features/ui/snackbar/store';
|
||||
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 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>
|
||||
|
||||
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
|
||||
<Field.Group class="mx-auto w-full space-y-2">
|
||||
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
|
||||
|
||||
{#if !$isOrgAdmin}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.tabs.api')}
|
||||
description={$t('automation.coming_soon')}
|
||||
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>
|
||||
|
||||
<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 { 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';
|
||||
@@ -97,6 +98,7 @@
|
||||
});
|
||||
|
||||
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
|
||||
const hasActiveMcpKey = $derived(mcpKeys.some((key) => !key.revokedAt));
|
||||
</script>
|
||||
|
||||
<Field.Group class="mx-auto w-full space-y-2">
|
||||
@@ -209,16 +211,24 @@
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
<Empty
|
||||
title={$t('automation.keys.empty_title')}
|
||||
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}
|
||||
</div>
|
||||
|
||||
{#if hasActiveMcpKey}
|
||||
<Field.Set class="gap-3!">
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<SparklesIcon class="size-5" />
|
||||
@@ -267,6 +277,7 @@
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Field.Set>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
|
||||
<Dialog.Root
|
||||
|
||||
@@ -9,14 +9,14 @@ import type {
|
||||
UpvotePostRequest
|
||||
} from '../utils/types';
|
||||
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 { coursesApi } from '$features/course/api/courses.svelte';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { get } from 'svelte/store';
|
||||
import { basePath, isStudentExperience } from '$lib/utils/store/app';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile as profileStore } from '$lib/utils/store/user';
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
/>
|
||||
<Page.Title>{communityApi.question.title}</Page.Title>
|
||||
</div>
|
||||
<Page.Subtitle>{$t('community.question.page_subtitle')}</Page.Subtitle>
|
||||
{/if}
|
||||
</Page.HeaderContent>
|
||||
{#if communityApi.question.authorId === $profile.id}
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
let isCoursesFetched = $state(false);
|
||||
$effect(() => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
if (!$profile.id || !$currentOrg.id || isCoursesFetched) return;
|
||||
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
isCoursesFetched = true;
|
||||
});
|
||||
|
||||
let filteredQuestions = $derived(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
GetOrgCoursesRequest,
|
||||
GetOrgCoursesRequestQuery,
|
||||
GetRecommendedCoursesRequest,
|
||||
GetUserEnrolledCoursesRequest,
|
||||
OrgCourses,
|
||||
OrgCoursesPagination,
|
||||
OrgCoursesQuery,
|
||||
RecommendedCourses,
|
||||
UserEnrolledCourses
|
||||
} from '$features/course/types';
|
||||
@@ -15,8 +18,15 @@ import { SvelteSet } from 'svelte/reactivity';
|
||||
*/
|
||||
export class CoursesApi extends BaseApiWithErrors {
|
||||
orgCourses = $state<OrgCourses>([]);
|
||||
orgCoursesPagination = $state<OrgCoursesPagination | null>(null);
|
||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||
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). */
|
||||
removeCourseFromLists(courseId: string) {
|
||||
@@ -30,26 +40,79 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
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: () =>
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagSlugs.length > 0
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagSlugs.join(',')
|
||||
{
|
||||
query: requestQuery
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: requestSignal
|
||||
}
|
||||
}
|
||||
: { query: {} }
|
||||
),
|
||||
logContext: 'fetching org courses',
|
||||
onSuccess: (response) => {
|
||||
if (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) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Post updated successfully');
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
};
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
@@ -232,21 +231,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
logContext: 'updating newsfeed reaction',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
};
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
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 CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
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 pluralize from 'pluralize';
|
||||
@@ -30,9 +28,11 @@
|
||||
isOnLandingPage?: boolean;
|
||||
isLMS?: boolean;
|
||||
isExplore?: boolean;
|
||||
href?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props();
|
||||
let { course, isOnLandingPage, isLMS, isExplore, href, actions }: Props = $props();
|
||||
|
||||
let {
|
||||
bannerImage,
|
||||
@@ -42,6 +42,7 @@
|
||||
description = '',
|
||||
isPublished = false,
|
||||
totalLessons = 0,
|
||||
totalExercises = 0,
|
||||
totalStudents = 0,
|
||||
currency = 'USD',
|
||||
progressRate = 45,
|
||||
@@ -54,7 +55,7 @@
|
||||
} = $derived({
|
||||
id: course.id,
|
||||
slug: course.slug,
|
||||
bannerImage: course.logo || '/images/classroomio-course-img-template.jpg',
|
||||
bannerImage: course.logo || DEFAULT_COURSE_BANNER_IMAGE,
|
||||
title: course.title,
|
||||
type: course.type,
|
||||
description: course.description,
|
||||
@@ -66,6 +67,10 @@
|
||||
},
|
||||
currency: course.currency,
|
||||
totalLessons: course.lessonCount,
|
||||
totalExercises: (() => {
|
||||
const c = course as { exerciseCount?: number };
|
||||
return typeof c.exerciseCount === 'number' ? c.exerciseCount : 0;
|
||||
})(),
|
||||
progressRate: (() => {
|
||||
const c = course as { exerciseCount?: number; exercisesCompleted?: number };
|
||||
if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') {
|
||||
@@ -122,49 +127,54 @@
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
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>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] p-3!">
|
||||
{#snippet child({ props })}
|
||||
<a href={resolve(courseUrl, {})} {...props}>
|
||||
{#if !isLMS && !isOnLandingPage}
|
||||
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
|
||||
{#snippet media()}
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet overlay()}
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Item.Header>
|
||||
<Item.Media variant="image" class="relative h-50! w-full!">
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
|
||||
{#if type}
|
||||
{@const tag = COURSE_TAG[type]}
|
||||
<Badge class="absolute bottom-2 left-2 z-10 flex items-center rounded-md! capitalize" variant="secondary">
|
||||
<tag.icon class={tag.iconStyle} />
|
||||
{tag.label}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Item.Media>
|
||||
</Item.Header>
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{title}
|
||||
</Item.Title>
|
||||
|
||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
||||
{#snippet tags()}
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex justify-between {isLMS && 'items-center'} w-full">
|
||||
<div class="w-[50%]">
|
||||
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
|
||||
<div class="w-[60%]">
|
||||
{#if isLMS || isOnLandingPage}
|
||||
<p class="text-xs {!isLMS && 'pl-2'} flex gap-1 dark:text-white">
|
||||
<span>
|
||||
{totalLessons}
|
||||
{$t('courses.course_card.lessons_number')}
|
||||
</span>
|
||||
&
|
||||
<span>
|
||||
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="py-2 text-xs">
|
||||
{#if isOnLandingPage}
|
||||
<span class="px-2">
|
||||
@@ -199,14 +209,22 @@
|
||||
<ArrowRightIcon class="custom" />
|
||||
</Button>
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-between">
|
||||
<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>
|
||||
</Item.Content>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
</CourseCard>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
COURSE_SORT_OPTIONS,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type CourseSortBy
|
||||
type CourseSortBy,
|
||||
type CourseSortOrder
|
||||
} 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 { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface FilterTag {
|
||||
@@ -54,7 +49,9 @@
|
||||
onClearFilters = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
const translatedSortOptions = $derived(
|
||||
sortOptions.map((option) => ({ value: option.value, label: $t(option.label) }))
|
||||
);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
@@ -65,74 +62,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('courses.tag_filters.filter')} tooltip={$t('courses.tag_filters.filter')}>
|
||||
{#if isFiltering}
|
||||
<Spinner class="size-4" />
|
||||
{:else}
|
||||
<FilterIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
<span
|
||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('courses.tag_filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{$t(option.label)}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<SortPopover
|
||||
sortOptions={translatedSortOptions}
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
defaultSortKey={DEFAULT_COURSE_SORT}
|
||||
defaultSortOrder={DEFAULT_SORT_ORDER}
|
||||
{isFiltering}
|
||||
{hasActiveFilters}
|
||||
{onClearFilters}
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<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}
|
||||
@@ -148,7 +88,6 @@
|
||||
<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
|
||||
@@ -176,6 +115,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/snippet}
|
||||
</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;
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
@@ -36,23 +36,36 @@
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
function applyItemOrder(nextItems: Array<Question>) {
|
||||
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;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
items.forEach((item, index) => {
|
||||
const order = index + 1;
|
||||
$questionnaire.questions.some((q) => {
|
||||
if (q.id === item.id) {
|
||||
q.isDirty = true;
|
||||
q.order = order;
|
||||
return true;
|
||||
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]
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
|
||||
$questionnaireOrder.open = false;
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
applyItemOrder(e.detail.items);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -63,7 +76,9 @@
|
||||
<Dialog.Root
|
||||
bind:open={$questionnaireOrder.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
if (!isOpen) {
|
||||
applyItemOrder(items);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96">
|
||||
@@ -81,7 +96,7 @@
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
class="max-h-[300px] w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
|
||||
@@ -176,6 +176,29 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'numeric.review.out_of_range': t.get(
|
||||
'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(
|
||||
'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(
|
||||
'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(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||
),
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
case 'RADIO':
|
||||
case 'TRUE_FALSE':
|
||||
case 'NUMERIC':
|
||||
case 'STAR':
|
||||
return true;
|
||||
case 'CHECKBOX':
|
||||
return answerValue.optionIds.length > 0;
|
||||
@@ -244,6 +245,10 @@
|
||||
return answerValue.text.trim().length > 0;
|
||||
case 'FILL_BLANK':
|
||||
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':
|
||||
return !!answerValue.fileKey;
|
||||
case 'MATCHING':
|
||||
@@ -470,9 +475,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
|
||||
<div class="mb-6">
|
||||
<Progress value={$questionnaireMetaData.progressValue} />
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
|
||||
<div class="mb-6 flex min-w-0 items-center gap-3">
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
@@ -605,7 +613,9 @@
|
||||
{/if}
|
||||
|
||||
<Preview
|
||||
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
||||
questions={[...$questionnaire.questions].sort(
|
||||
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
|
||||
)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
grades={$questionnaireMetaData.grades}
|
||||
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 CreateCourseButton } from './create-course-button.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 CourseListLoader } from './list-loader.svelte';
|
||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import UploadVideo from './upload-video.svelte';
|
||||
import EmbedLink from './embed-link.svelte';
|
||||
import LibraryVideo from './library-video.svelte';
|
||||
import GoogleDriveVideo from './google-drive-video.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
@@ -57,6 +58,9 @@
|
||||
<UnderlineTabs.Content value="4" class="mt-3">
|
||||
<LibraryVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="5" class="mt-3">
|
||||
<GoogleDriveVideo {lessonId} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
|
||||
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
|
||||
import LibraryIcon from '@lucide/svelte/icons/library';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import YoutubeIcon from '@lucide/svelte/icons/youtube';
|
||||
@@ -23,5 +24,10 @@ export const videoTabs = [
|
||||
value: 4,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.library',
|
||||
icon: LibraryIcon
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.google_drive',
|
||||
icon: HardDriveIcon
|
||||
}
|
||||
];
|
||||
|
||||
+152
@@ -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;
|
||||
createdAt?: 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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -67,6 +76,7 @@ export function getVideoTitle(video: LessonVideo, index: number): string {
|
||||
|
||||
if (video.type === 'youtube') return 'YouTube video';
|
||||
if (video.type === 'generic') return 'Embedded video';
|
||||
if (video.type === 'google_drive') return 'Google Drive video';
|
||||
|
||||
return `Video ${index + 1}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Image } from '$features/ui';
|
||||
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 {
|
||||
getVideoThumbnailUrl,
|
||||
@@ -67,6 +67,8 @@
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
@@ -74,8 +76,10 @@
|
||||
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2">{title}</span>
|
||||
<ExternalLinkIcon class="ui:text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
<ExternalLinkIcon {isHovered} size={14} class="ui:text-muted-foreground shrink-0" ariaHidden={true} />
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
totalStudents?: number;
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
href?: string;
|
||||
showActions?: boolean;
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -40,10 +42,17 @@
|
||||
totalStudents = 0,
|
||||
isExplore = false,
|
||||
isLMS = false,
|
||||
href,
|
||||
showActions = true,
|
||||
tags = []
|
||||
}: Props = $props();
|
||||
|
||||
function handleRowClick() {
|
||||
if (href) {
|
||||
goto(resolve(href, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
@@ -104,7 +113,7 @@
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
{#if !isLMS && showActions}
|
||||
<Table.Cell class="text-center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import { newsfeedApi } from '$features/course/api';
|
||||
import { getNewsfeedReactionCounts, getSelectedNewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
import DeleteConfirmation from './delete-confirmation.svelte';
|
||||
import type { NewsfeedCommentsByFeedId } from '$features/course/api';
|
||||
import Header from './header.svelte';
|
||||
import Reactions from './reactions.svelte';
|
||||
import { NewsfeedReactions } from '@cio/ui/custom/newsfeed-reactions';
|
||||
import Comments from './comments.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -64,6 +65,9 @@
|
||||
deleteFeed(feed.id);
|
||||
};
|
||||
|
||||
const reactionCounts = $derived(getNewsfeedReactionCounts(feed.reaction));
|
||||
const selectedReactionType = $derived(getSelectedNewsfeedReactionType(feed.reaction, author.id));
|
||||
|
||||
onMount(() => {
|
||||
if (isActive) {
|
||||
const el = document.getElementById(feed.id);
|
||||
@@ -84,7 +88,14 @@
|
||||
>
|
||||
<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
|
||||
{courseId}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
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 UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
@@ -24,11 +26,17 @@
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.authorAvatarUrl || ''}
|
||||
<Avatar.Root class="h-full w-full">
|
||||
<Avatar.Image
|
||||
src={feed.authorAvatarUrl ?? ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
<UserIcon class="text-muted-foreground h-5 w-5" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
<span>
|
||||
<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 { orgApi } from '$features/org/api/org.svelte';
|
||||
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 { OrgStudent, Tutor } from './types';
|
||||
|
||||
import TutorSelectSection from './tutor-select-section.svelte';
|
||||
import ExistingStudentsSection from './existing-students-section.svelte';
|
||||
import BulkEmailSection from './bulk-email-section.svelte';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
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() {
|
||||
if (!selectedTutors.length) {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
@@ -144,7 +192,12 @@
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<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">
|
||||
<Button variant="secondary" type="button" onclick={onSubmit} loading={peopleApi.isLoading}>
|
||||
@@ -156,8 +209,13 @@
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
|
||||
<BulkEmailSection {courseId} />
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user