diff --git a/ARCHITECHTURE.md b/ARCHITECHTURE.md index f8960a0e..2ab75688 100644 --- a/ARCHITECHTURE.md +++ b/ARCHITECHTURE.md @@ -826,6 +826,107 @@ import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; - Use `apiKeyMiddleware` for API-key-only endpoints - Use `authOrApiKeyMiddleware` for endpoints that accept both user sessions and API keys +#### 6. **Role-Based Authorization Middleware** + +**Middleware Location Decision:** + +Use **generic middleware** (`apps/api/src/middlewares/`) when: +- ✅ The middleware is reusable across multiple routes/domains +- ✅ The pattern is generic (e.g., "is user member of org?") +- ✅ The check doesn't depend on specific resource types + +Use **route-specific middleware** (`apps/api/src/routes/{domain}/middlewares/`) when: +- ✅ The middleware is specific to one route file +- ✅ The check involves resource-specific data (e.g., "is user author of THIS question?") +- ✅ The pattern won't be reused elsewhere + +**Important:** Never put authorization logic inline in route handlers. Always create middleware functions, even if they're only used once. + +**Available Generic Middleware:** + +1. **`orgMemberMiddleware`** - User must be a member of the organization (any role) + - Location: `apps/api/src/middlewares/org-member.ts` + - Use for: Organization-scoped operations + - Requires: `cio-org-id` header + +2. **`orgTeamMemberMiddleware`** - User must be ADMIN or TUTOR in organization + - Location: `apps/api/src/middlewares/org-team-member.ts` + - Use for: Admin-only operations across organization + - Requires: `cio-org-id` header + +3. **`orgAdminMiddleware`** - User must be ADMIN in organization + - Location: `apps/api/src/middlewares/org-admin.ts` (already exists) + - Use for: Admin-only operations + - Requires: `cio-org-id` header + +4. **`courseMemberMiddleware`** - User must be a member of the course's group + - Location: `apps/api/src/middlewares/course-member.ts` + - Use for: Course-scoped operations + - Requires: `courseId` in request params or query + +**Route-Specific Middleware Pattern:** + +For endpoint-specific authorization, create middleware in the route's middleware folder: + +``` +apps/api/src/routes/community/ + ├── community.ts + └── middlewares/ + ├── question-author-or-team.ts + ├── comment-author-or-team.ts + └── question-course-member.ts +``` + +**Example Route-Specific Middleware:** + +```typescript +// apps/api/src/routes/community/middlewares/question-author-or-team.ts +import { Context, Next } from 'hono'; +import { ErrorCodes } from '@api/utils/errors'; +import { getQuestionAuthorAndCourse } from '@cio/db/queries/community'; +import { isUserCourseTeamMember } from '@cio/db/queries/group'; + +export const questionAuthorOrTeamMiddleware = async (c: Context, next: Next) => { + const user = c.get('user'); + const questionId = c.req.param('id'); + + const question = await getQuestionAuthorAndCourse(questionId); + if (!question) { + return c.json({ success: false, error: 'Question not found' }, 404); + } + + const isAuthor = question.authorId === user.id; + const isTeamMember = await isUserCourseTeamMember(question.courseId, user.id); + + if (!isAuthor && !isTeamMember) { + return c.json({ success: false, error: 'Unauthorized' }, 403); + } + + await next(); +}; +``` + +**Using Route-Specific Middleware:** + +```typescript +// apps/api/src/routes/community/community.ts +import { questionAuthorOrTeamMiddleware } from './middlewares/question-author-or-team'; + +export const communityRouter = new Hono() + .put('/:id', authMiddleware, questionAuthorOrTeamMiddleware, zValidator('param', ZGetCommunity), async (c) => { + // Handler logic + }); +``` + +**Best Practices:** +- Always use `authMiddleware` first +- Extract IDs from validated params/body +- Return 404 if resource doesn't exist +- Return 403 if user lacks permission +- Use existing `ErrorCodes` enum +- Keep middleware focused and single-purpose +- Never inline authorization checks - always create middleware functions + ### Complete Route Example Routes should be thin - they handle HTTP concerns and delegate to services: @@ -1699,10 +1800,117 @@ try { ### Complete Frontend Example -Example with **client-side validation** before API call: +**✅ CORRECT Pattern - API Class Handles Everything:** + +```typescript +// apps/dashboard/src/lib/features/course/api/course.svelte.ts +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { ZCourseCreate } from '@cio/utils/validation/course'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { snackbar } from '$features/ui/snackbar/store'; +import { goto } from '$app/navigation'; + +class CourseApi extends BaseApiWithErrors { + async createCourse(fields: { title: string; slug: string; description?: string }) { + // 1. CLIENT-SIDE VALIDATION + const result = ZCourseCreate.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'course'); + return; + } + + // 2. API CALL WITH COMPLETE SUCCESS/ERROR HANDLING + await this.execute({ + requestFn: () => classroomio.course.create.$post({ + json: result.data + }), + logContext: 'creating course', + onSuccess: (response) => { + if (response.data?.course) { + // Show success message + snackbar.success('Course created successfully'); + + // Navigate to the course + goto(`/courses/${response.data.course.slug}`); + + // Mark as successful + this.success = true; + this.errors = {}; + } + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error('Failed to create course'); + return; + } + if ('error' in result && 'field' in result && result.field) { + // Field-specific error (e.g., duplicate slug) + this.errors[result.field] = result.error; + snackbar.error(result.error); + } else if ('error' in result) { + // General error + this.errors.general = result.error; + snackbar.error(result.error); + } + } + }); + } +} + +export const courseApi = new CourseApi(); +``` ```typescript // apps/dashboard/src/routes/course/+page.svelte + + +
courseApi.createCourse(fields)}> +
+ + + {#if courseApi.errors.title} + {courseApi.errors.title} + {/if} +
+ +
+ + + {#if courseApi.errors.slug} + {courseApi.errors.slug} + {/if} +
+ + +
+``` + +**Key Differences:** +- ✅ **API class handles validation** - Component just calls the method +- ✅ **API class handles loading state** - Component binds to `api.isLoading` +- ✅ **API class handles errors** - Component binds to `api.errors.fieldName` +- ✅ **API class handles success** - Navigation and snackbar in `onSuccess` +- ✅ **Component is thin** - Only renders UI and binds to API state +- ✅ **No handleSubmit function** - Direct call to API method + +**❌ WRONG Pattern - Logic in Component:** + +```typescript +// DON'T DO THIS - -
-
- - - {#if errors.title} - {errors.title} - {/if} -
- -
- - - {#if errors.slug} - {errors.slug} - {/if} -
- - -
``` ### API Abstraction Pattern (API Classes) -When creating API abstractions in `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`, **always use the `execute` method** from `BaseApi` or `BaseApiWithErrors` classes. This provides automatic loading state management, error handling, and consistent response processing. +When creating API abstractions in `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`, **the API class should be a complete abstraction** that handles everything. Components should have NO business logic related to API calls. **Important**: - **`.svelte.ts` files** are for **client-side use only** (Svelte components). They use Svelte reactivity (`$state`, `$derived`) and manage component state. **Never** include API key logic in these files. - **`.server.ts` files** are for **server-side use only** (`+layout.server.ts`, `+page.server.ts`, `+server.ts`). They contain static methods with API key authentication and are automatically excluded from client bundles by SvelteKit. -#### Using the `execute` Method +#### Critical Principle: API Classes Handle Everything -**Pattern:** +**❌ WRONG - Logic in Component:** ```typescript -import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +// Component file - TOO MUCH LOGIC +async function handleSave() { + // ❌ Validation in component + errors = customValidation(fields); + if (Object.keys(errors).length) return; -class MyApi extends BaseApiWithErrors { - async updateItem(id: string, data: Partial) { - await this.execute({ - requestFn: () => classroomio.items[':id'].$put({ - param: { id }, - json: data + // ❌ Business logic in component (slug generation, defaults) + const data = await api.create({ + ...fields, + slug: generateSlug(fields.title), + votes: 0, + authorId: $profile.id + }); + + // ❌ Manual error/success handling + if (api.error) { + snackbar.error('Error occurred'); + } else { + snackbar.success('Success!'); + goto(`/path/${data.slug}`); + } +} +``` + +**✅ CORRECT - All Logic in API Class:** +```typescript +// Component file - THIN AND CLEAN + + +
+ + {#if api.errors.title} + {api.errors.title} + {/if} + + +
+``` + +```typescript +// API class - COMPLETE ABSTRACTION +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { ZQuestionCreate } from '@cio/utils/validation/community'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { snackbar } from '$features/ui/snackbar/store'; +import { goto } from '$app/navigation'; +import { profile } from '$lib/utils/store/user'; +import { currentOrg, currentOrgPath } from '$lib/utils/store/org'; +import { get } from 'svelte/store'; + +class CommunityApi extends BaseApiWithErrors { + questionSlug = $state(''); + + async createQuestion(fields: { title: string; body: string; courseId?: string }) { + // 1. CLIENT-SIDE VALIDATION + const result = ZQuestionCreate.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'community'); + return; + } + + // 2. API CALL WITH SUCCESS/ERROR HANDLING + await this.execute({ + requestFn: () => classroomio.community.questions.$post({ + json: result.data }), - logContext: 'updating item', + logContext: 'creating community question', onSuccess: (response) => { - // Update local stores with response.data - itemsStore.update((items) => { - const index = items.findIndex((i) => i.id === id); - if (index !== -1 && response.data) { - items[index] = { ...items[index], ...response.data }; - } - return items; - }); + if (response.data) { + // Store the slug for navigation + this.questionSlug = response.data.slug; + + // Show success message + snackbar.success('snackbar.community.success.question_submitted'); + + // Navigate to the question + goto(`${get(currentOrgPath)}/community/${response.data.slug}`); + + // Mark as successful + this.success = true; + this.errors = {}; + } }, onError: (result) => { - // Optional: Custom error handling if (typeof result === 'string') { - snackbar.error(result); + snackbar.error('snackbar.community.error.try_again'); return; } if ('error' in result) { @@ -1837,54 +2085,106 @@ class MyApi extends BaseApiWithErrors { } } -export const myApi = new MyApi(); +export const communityApi = new CommunityApi(); +``` + +#### What API Classes Should Handle + +**✅ API Class Responsibilities:** +1. **Client-side validation** using shared Zod schemas +2. **Loading state** (`this.isLoading` - automatic via `execute`) +3. **Error state** (`this.errors` for field errors, `this.error` for general errors) +4. **Success handling** (update stores, show snackbar, navigate) +5. **Error handling** (show snackbar, map validation errors) +6. **Business logic** (format data, generate IDs/slugs if needed client-side) +7. **Navigation** (redirect after success) +8. **State management** (update local stores with response data) + +**❌ Component Responsibilities:** +1. ~~Validation~~ → API class handles this +2. ~~Error handling~~ → API class handles this +3. ~~Success callbacks~~ → API class handles this +4. ~~Navigation~~ → API class handles this +5. ~~Business logic~~ → API class or service layer handles this + +**✅ Component Responsibilities:** +1. **Render UI** (forms, inputs, buttons) +2. **Bind to form fields** (`bind:value={fields.title}`) +3. **Call API method** (`await api.createQuestion(fields)`) +4. **Display loading state** (`{api.isLoading ? 'Loading...' : 'Submit'}`) +5. **Display field errors** (`{#if api.errors.title}{api.errors.title}{/if}`) + +#### Using the `execute` Method + +**Pattern:** +```typescript +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { ZItemUpdate } from '@cio/utils/validation/items'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { snackbar } from '$features/ui/snackbar/store'; +import { goto } from '$app/navigation'; + +class ItemApi extends BaseApiWithErrors { + async updateItem(id: string, fields: Partial) { + // 1. CLIENT-SIDE VALIDATION + const result = ZItemUpdate.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'item'); + return; + } + + // 2. API CALL + await this.execute({ + requestFn: () => classroomio.items[':id'].$put({ + param: { id }, + json: result.data + }), + logContext: 'updating item', + onSuccess: (response) => { + // Update local stores + itemsStore.update((items) => { + const index = items.findIndex((i) => i.id === id); + if (index !== -1 && response.data) { + items[index] = { ...items[index], ...response.data }; + } + return items; + }); + + // Show success message + snackbar.success('Item updated successfully'); + + // Navigate if needed + goto(`/items/${id}`); + + // Mark as successful + this.success = true; + this.errors = {}; + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error('Failed to update item'); + return; + } + if ('error' in result) { + this.handleValidationError(result); + } + } + }); + } +} + +export const itemApi = new ItemApi(); ``` **Key Points:** - **Always use `this.execute`** - Never call the RPC client directly +- **Always validate first** - Use Zod's `safeParse()` before API call - **Type the execute call** with `typeof classroomio.{route}.{method}` for type safety - **Use `requestFn`** - Wrap the RPC call in a function - **Use `logContext`** - Provide descriptive context for error logging -- **Use `onSuccess`** - Update local stores/reactivity with response data -- **Use `onError`** - Optional custom error handling (validation errors are handled automatically by `BaseApiWithErrors`) - -**Examples:** - -```typescript -// Profile API - Simple update -await this.execute({ - requestFn: () => classroomio.account.user.$put({ json: profileUpdates }), - logContext: 'updating profile', - onSuccess: (response) => { - profileStore.update((_profile) => ({ - ..._profile, - ...response.profile - })); - } -}); - -// Onboarding API - With error handling -await this.execute({ - requestFn: () => classroomio.onboarding.step1.$post({ json: data }), - logContext: 'submitting organization setup', - onSuccess: (result) => { - const { organizations } = result.data; - orgs.set(organizations); - currentOrg.set(organizations[0]); - this.errors = {}; - this.step = ONBOARDING_STEPS.USER_METADATA; - }, - onError: (result) => { - if (typeof result === 'string') { - snackbar.error(result); - return; - } - if ('error' in result) { - this.handleValidationError(result); - } - } -}); -``` +- **Use `onSuccess`** - Handle ALL success logic (stores, snackbar, navigation, state updates) +- **Use `onError`** - Handle ALL error logic (snackbar, validation errors) +- **Return early on validation failure** - Don't make API call if validation fails **Benefits:** - ✅ Automatic loading state management (`this.isLoading`) @@ -1893,6 +2193,9 @@ await this.execute({ - ✅ Type-safe responses - ✅ Centralized logging - ✅ Easy to test and mock +- ✅ **Components stay thin and focused on UI** +- ✅ **All business logic in one place** +- ✅ **Single source of truth for validation** ### Server-Side API Calls @@ -2040,35 +2343,65 @@ const result = await OrgPlanApiServer.createOrgPlan(planData); #### ✅ DO -- **Use type-safe RPC client** for API calls (Hono RPC) +**API Class Design:** +- **Handle ALL logic in API classes** - validation, loading, errors, success/failure, navigation - **Always use `execute` method** in API abstraction classes (never call RPC client directly) -- **Use `.server.ts` API classes** in server-side files (`+layout.server.ts`, `+page.server.ts`, `+server.ts`) -- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication -- **Use API key** for server-to-server communication (webhooks, external calls) -- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation` +- **Always validate first** - Use `ZodSchema.safeParse()` before making API calls +- **Handle success in `onSuccess`** - Update stores, show snackbar, navigate, reset errors +- **Handle errors in `onError`** - Show snackbar, map validation errors to `this.errors` +- **Extend `BaseApiWithErrors`** for forms (provides `this.errors` for field-level validation) +- **Extend `BaseApi`** for simple API calls (provides `this.error` for general errors) - **Use entity-specific translations** when multiple entities share field names ```typescript - mapZodErrorsToTranslations(error, 'course'); // "Course title is required" + this.errors = mapZodErrorsToTranslations(error, 'course'); // "Course title is required" ``` +**Component Design:** +- **Keep components thin** - Only render UI and call API methods +- **Bind to API state** - Use `api.isLoading`, `api.errors.fieldName`, `api.success` +- **Call API methods directly** - No `handleSave()` or `handleSubmit()` functions with logic +- **Display field errors** - Show `api.errors.fieldName` next to form fields +- **Display loading states** - Disable buttons with `api.isLoading` + +**Server-Side:** +- **Use `.server.ts` API classes** in server-side files (`+layout.server.ts`, `+page.server.ts`, `+server.ts`) +- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication +- **Use API key** for server-to-server communication (webhooks, external calls) + +**General:** +- **Use type-safe RPC client** for API calls (Hono RPC) +- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation` - **Provide generic translations** as fallbacks for shared fields (email, password) -- **Handle both API errors and network errors** separately -- **Show specific field errors** when validation or API returns them -- **Keep UI responsive** during API calls (loading states) -- **Optimistically update UI** when appropriate -- **Handle different HTTP status codes** appropriately - **Show user-friendly, translated error messages** #### ❌ DON'T -- **Call RPC client directly** in API classes (always use `execute` method) -- **Call RPC client directly in server-side files** - use `.server.ts` API classes instead -- **Use `.svelte.ts` API classes in server-side files** - they're for client-side Svelte components only -- **Include API key logic in `.svelte.ts` files** - API keys must only be in `.server.ts` files +**API Class Anti-Patterns:** +- **DON'T call RPC client directly** in API classes (always use `execute` method) +- **DON'T return data from API methods** - Handle success in `onSuccess` callback +- **DON'T let components handle validation** - Validate in API class before API call +- **DON'T let components handle navigation** - Navigate in `onSuccess` callback +- **DON'T let components handle snackbar messages** - Show snackbar in `onSuccess`/`onError` +- **DON'T skip client-side validation** - Always validate before making API call +- **DON'T make API call if validation fails** - Return early on validation errors + +**Component Anti-Patterns:** +- **DON'T write `handleSave()` functions with business logic** - Call API method directly +- **DON'T manually check `if (api.error)`** - API class handles errors in `onError` +- **DON'T manually call `goto()`** - API class handles navigation in `onSuccess` +- **DON'T manually call `snackbar`** - API class handles snackbar in callbacks +- **DON'T pass business logic data** (like `authorId`, `votes`, `slug`) - API class or service handles this +- **DON'T perform validation in components** - API class validates before API call + +**Server-Side:** +- **DON'T call RPC client directly in server-side files** - use `.server.ts` API classes instead +- **DON'T use `.svelte.ts` API classes in server-side files** - they're for client-side Svelte components only +- **DON'T include API key logic in `.svelte.ts` files** - API keys must only be in `.server.ts` files + +**General:** - Skip error handling assuming API calls always succeed - Display raw API error messages without formatting -- Skip client-side validation (validate before API call for instant feedback) - Hardcode error messages in schemas (use translation system) - Use the same generic message for all entities (use entity-specific when needed) - Make multiple simultaneous calls that should be atomic diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 5af74aba..75ea0dc2 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -17,6 +17,7 @@ import { dashAnalyticsRouter } from '@api/routes/dash'; import { mediaRouter } from '@api/routes/media'; import { onboardingRouter } from '@api/routes/onboarding'; import { organizationRouter } from '@api/routes/organization'; +import { communityRouter } from '@api/routes/community'; // Create Hono app with chaining for RPC support export const app = new Hono() @@ -77,6 +78,7 @@ export const app = new Hono() .route('/media', mediaRouter) .route('/organization', organizationRouter) .route('/dash', dashAnalyticsRouter) + .route('/community', communityRouter) // Error handling .onError((err, c) => { diff --git a/apps/api/src/middlewares/course-member.ts b/apps/api/src/middlewares/course-member.ts new file mode 100644 index 00000000..e5005d61 --- /dev/null +++ b/apps/api/src/middlewares/course-member.ts @@ -0,0 +1,64 @@ +import { Context, Next } from 'hono'; + +import { ErrorCodes } from '@api/utils/errors'; +import { isUserCourseMember } from '@cio/db/queries/group'; + +/** + * Middleware to check if the authenticated user is a member of a course's group + * Requires authMiddleware to be applied first + * Extracts courseId from request params, query, or body + */ +export const courseMemberMiddleware = async (c: Context, next: Next) => { + try { + const user = c.get('user'); + if (!user) { + return c.json( + { + success: false, + error: 'Unauthorized', + code: 'UNAUTHORIZED' + }, + 401 + ); + } + + // Try to get courseId from params or query + // Note: Body parsing should happen after validation middleware + const courseId = c.req.param('courseId') || c.req.query('courseId'); + + if (!courseId) { + return c.json( + { + success: false, + error: 'Course ID is required', + code: 'COURSE_ID_REQUIRED' + }, + 400 + ); + } + + const isMember = await isUserCourseMember(courseId, user.id); + if (!isMember) { + return c.json( + { + success: false, + error: 'You must be a member of this course to perform this action', + code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED + }, + 403 + ); + } + + await next(); + } catch (error) { + console.error('Error in courseMemberMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify course membership', + code: 'COURSE_MEMBER_CHECK_FAILED' + }, + 500 + ); + } +}; diff --git a/apps/api/src/middlewares/org-member.ts b/apps/api/src/middlewares/org-member.ts new file mode 100644 index 00000000..b7898c20 --- /dev/null +++ b/apps/api/src/middlewares/org-member.ts @@ -0,0 +1,62 @@ +import { Context, Next } from 'hono'; + +import { ErrorCodes } from '@api/utils/errors'; +import { isUserOrgMember } from '@cio/db/queries/organization'; + +/** + * Middleware to check if the authenticated user is a member of the organization + * Requires authMiddleware to be applied first + * Expects orgId in the 'cio-org-id' header + */ +export const orgMemberMiddleware = async (c: Context, next: Next) => { + try { + const user = c.get('user'); + if (!user) { + return c.json( + { + success: false, + error: 'Unauthorized', + code: 'UNAUTHORIZED' + }, + 401 + ); + } + + const orgId = c.req.header('cio-org-id'); + + if (!orgId) { + return c.json( + { + success: false, + error: 'Organization ID is required', + code: 'ORG_ID_REQUIRED' + }, + 400 + ); + } + + const isMember = await isUserOrgMember(orgId, user.id); + if (!isMember) { + return c.json( + { + success: false, + error: 'UNAUTHORIZED', + code: 'UNAUTHORIZED' + }, + 403 + ); + } + + await next(); + } catch (error) { + console.error('Error in orgMemberMiddleware:', error); + return c.json( + { + success: false, + error: 'INTERNAL_ERROR', + code: 'INTERNAL_ERROR' + }, + 500 + ); + } +}; diff --git a/apps/api/src/middlewares/org-team-member.ts b/apps/api/src/middlewares/org-team-member.ts new file mode 100644 index 00000000..6366fde1 --- /dev/null +++ b/apps/api/src/middlewares/org-team-member.ts @@ -0,0 +1,62 @@ +import { Context, Next } from 'hono'; + +import { ErrorCodes } from '@api/utils/errors'; +import { isUserOrgTeamMember } from '@cio/db/queries/organization'; + +/** + * Middleware to check if the authenticated user is a team member (ADMIN or TUTOR) of the organization + * Requires authMiddleware to be applied first + * Expects orgId in the 'cio-org-id' header + */ +export const orgTeamMemberMiddleware = async (c: Context, next: Next) => { + try { + const user = c.get('user'); + if (!user) { + return c.json( + { + success: false, + error: 'Unauthorized', + code: 'UNAUTHORIZED' + }, + 401 + ); + } + + const orgId = c.req.header('cio-org-id'); + + if (!orgId) { + return c.json( + { + success: false, + error: 'Organization ID is required', + code: 'ORG_ID_REQUIRED' + }, + 400 + ); + } + + const isTeamMember = await isUserOrgTeamMember(orgId, user.id); + if (!isTeamMember) { + return c.json( + { + success: false, + error: 'Only organization team members (admins or tutors) can perform this action', + code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED + }, + 403 + ); + } + + await next(); + } catch (error) { + console.error('Error in orgTeamMemberMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify organization team member status', + code: 'ORG_TEAM_MEMBER_CHECK_FAILED' + }, + 500 + ); + } +}; diff --git a/apps/api/src/routes/community/community.ts b/apps/api/src/routes/community/community.ts new file mode 100644 index 00000000..47c1d809 --- /dev/null +++ b/apps/api/src/routes/community/community.ts @@ -0,0 +1,175 @@ +import { + ZCommunityComment, + ZCommunityCommentDelete, + ZCommunityQuestion, + ZCommunityQuestionUpdate, + ZCommunityQuestions, + ZGetCommunity, + ZGetCommunityCommentQuestionId, + ZNewCommunityQuestion, + ZUpvotePost, + ZUpvotePostParam +} from '@cio/utils/validation/community'; +import { + createComment, + createQuestion, + deleteComment, + deleteCommentsByQuestionId, + deleteQuestion, + editQuestion, + fetchCommunityQuestion, + fetchCommunityQuestions, + upvote +} from '@api/services/community'; + +import { Hono } from '@api/utils/hono'; +import { authMiddleware } from '@api/middlewares/auth'; +import { commentAuthorOrTeamMiddleware } from './middlewares/comment-author-or-team'; +import { handleError } from '@api/utils/errors'; +import { orgMemberMiddleware } from '@api/middlewares/org-member'; +import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member'; +import { questionAuthorOrTeamMiddleware } from './middlewares/question-author-or-team'; +import { zValidator } from '@hono/zod-validator'; + +export const communityRouter = new Hono() + .get('/', authMiddleware, orgMemberMiddleware, zValidator('query', ZCommunityQuestions), async (c) => { + try { + const { orgId } = c.req.valid('query'); + + const result = await fetchCommunityQuestions(orgId); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load community posts'); + } + }) + .get('/:slug', authMiddleware, orgMemberMiddleware, zValidator('param', ZCommunityQuestion), async (c) => { + try { + const { slug } = c.req.valid('param'); + + const result = await fetchCommunityQuestion({ slug }); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load community post'); + } + }) + .post('/', authMiddleware, orgMemberMiddleware, zValidator('json', ZNewCommunityQuestion), async (c) => { + try { + const { title, body, courseId, organizationId, authorProfileId, votes, slug } = c.req.valid('json'); + + const result = await createQuestion({ + title, + body, + courseId, + organizationId, + authorProfileId, + votes, + slug + }); + + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create community post'); + } + }) + .put( + '/:id', + authMiddleware, + questionAuthorOrTeamMiddleware, + zValidator('param', ZGetCommunity), + zValidator('json', ZCommunityQuestionUpdate), + async (c) => { + try { + const { id } = c.req.valid('param'); + const { title, body, courseId } = c.req.valid('json'); + + const result = await editQuestion({ id, title, body, courseId }); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update community post'); + } + } + ) + .delete('/:id', authMiddleware, questionAuthorOrTeamMiddleware, zValidator('param', ZGetCommunity), async (c) => { + try { + const { id } = c.req.valid('param'); + + const result = await deleteQuestion({ id }); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to delete community post'); + } + }) + + .post( + '/:id/comment', + authMiddleware, + orgMemberMiddleware, + zValidator('param', ZGetCommunity), + zValidator('json', ZCommunityComment), + async (c) => { + try { + const { id } = c.req.valid('param'); + console.log('question id', id); + const { body, authorProfileId, votes } = c.req.valid('json'); + + const result = await createComment({ + body, + questionId: id, + authorProfileId, + votes + }); + + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to submit comment'); + } + } + ) + .post( + '/:id/upvote', + authMiddleware, + orgMemberMiddleware, + zValidator('param', ZUpvotePostParam), + zValidator('json', ZUpvotePost), + async (c) => { + try { + const { id } = c.req.valid('param'); + const { isQuestion } = c.req.valid('json'); + + const result = await upvote({ + id, + isQuestion + }); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to upvote post'); + } + } + ) + .delete( + '/:id/comment', + authMiddleware, + commentAuthorOrTeamMiddleware, + zValidator('param', ZCommunityCommentDelete), + async (c) => { + const { id } = c.req.valid('param'); + const result = await deleteComment({ id }); + return c.json({ success: true, data: result }, 200); + } + ) + .delete( + '/:questionId/comments', + authMiddleware, + orgTeamMemberMiddleware, + zValidator('param', ZGetCommunityCommentQuestionId), + async (c) => { + const { questionId } = c.req.valid('param'); + const result = await deleteCommentsByQuestionId({ questionId }); + return c.json({ success: true, data: result }, 200); + } + ); diff --git a/apps/api/src/routes/community/index.ts b/apps/api/src/routes/community/index.ts new file mode 100644 index 00000000..12bb9613 --- /dev/null +++ b/apps/api/src/routes/community/index.ts @@ -0,0 +1 @@ +export * from './community'; diff --git a/apps/api/src/routes/community/middlewares/comment-author-or-team.ts b/apps/api/src/routes/community/middlewares/comment-author-or-team.ts new file mode 100644 index 00000000..9dafb392 --- /dev/null +++ b/apps/api/src/routes/community/middlewares/comment-author-or-team.ts @@ -0,0 +1,81 @@ +import { Context, Next } from 'hono'; + +import { ErrorCodes } from '@api/utils/errors'; +import { getCommentAuthorAndCourse } from '@cio/db/queries/community'; +import { isUserCourseTeamMember } from '@cio/db/queries/group'; + +/** + * Middleware to check if user is comment author OR tutor/admin in course + * Requires authMiddleware to be applied first + * Expects comment ID in route params as 'id' + */ +export const commentAuthorOrTeamMiddleware = 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('id'); + if (!commentId) { + return c.json( + { + success: false, + error: 'Comment ID is required', + code: 'COMMENT_ID_REQUIRED' + }, + 400 + ); + } + + const comment = await getCommentAuthorAndCourse(commentId); + if (!comment) { + return c.json( + { + success: false, + error: 'Comment not found', + code: ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND + }, + 404 + ); + } + + const isAuthor = comment.authorId === user.id; + if (isAuthor) { + await next(); + return; + } + + const isTeamMember = await isUserCourseTeamMember(comment.courseId, user.id); + + if (!isTeamMember) { + return c.json( + { + success: false, + error: 'Only the author or course team members can perform this action', + code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED + }, + 403 + ); + } + + await next(); + } catch (error) { + console.error('Error in commentAuthorOrTeamMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify comment authorization', + code: 'COMMENT_AUTH_CHECK_FAILED' + }, + 500 + ); + } +}; diff --git a/apps/api/src/routes/community/middlewares/question-author-or-team.ts b/apps/api/src/routes/community/middlewares/question-author-or-team.ts new file mode 100644 index 00000000..22050ca8 --- /dev/null +++ b/apps/api/src/routes/community/middlewares/question-author-or-team.ts @@ -0,0 +1,76 @@ +import { Context, Next } from 'hono'; + +import { ErrorCodes } from '@api/utils/errors'; +import { getQuestionAuthorAndCourse } from '@cio/db/queries/community'; +import { isUserCourseTeamMember } from '@cio/db/queries/group'; + +/** + * Middleware to check if user is question author OR tutor/admin in course + * Requires authMiddleware to be applied first + * Expects question ID in route params as 'id' + */ +export const questionAuthorOrTeamMiddleware = async (c: Context, next: Next) => { + try { + const user = c.get('user'); + if (!user) { + return c.json( + { + success: false, + error: 'Unauthorized', + code: 'UNAUTHORIZED' + }, + 401 + ); + } + + const questionId = c.req.param('id'); + if (!questionId) { + return c.json( + { + success: false, + error: 'Question ID is required', + code: 'QUESTION_ID_REQUIRED' + }, + 400 + ); + } + + const question = await getQuestionAuthorAndCourse(questionId); + if (!question) { + return c.json( + { + success: false, + error: 'Question not found', + code: ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND + }, + 404 + ); + } + + const isAuthor = question.authorId === user.id; + const isTeamMember = await isUserCourseTeamMember(question.courseId, user.id); + + if (!isAuthor && !isTeamMember) { + return c.json( + { + success: false, + error: 'Only the author or course team members can perform this action', + code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED + }, + 403 + ); + } + + await next(); + } catch (error) { + console.error('Error in questionAuthorOrTeamMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify question authorization', + code: 'QUESTION_AUTH_CHECK_FAILED' + }, + 500 + ); + } +}; diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts index ae178515..6cd60825 100644 --- a/apps/api/src/routes/course/course.ts +++ b/apps/api/src/routes/course/course.ts @@ -9,17 +9,21 @@ import { import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; import { cloneCourse } from '@api/services/course/clone'; +import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { generateCertificate } from '@api/utils/certificate'; import { generateCoursePdf } from '@api/utils/course'; import { handleError } from '@api/utils/errors'; import { katexRouter } from '@api/routes/course/katex'; import { lessonRouter } from '@api/routes/course/lesson'; +import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { presignRouter } from '@api/routes/course/presign'; import { zValidator } from '@hono/zod-validator'; export const courseRouter = new Hono() .post( '/:courseId/download/certificate', + authMiddleware, + courseMemberMiddleware, zValidator('param', ZCourseDownloadParam), zValidator('json', ZCertificateDownload), async (c) => { @@ -42,6 +46,8 @@ export const courseRouter = new Hono() ) .post( '/:courseId/download/content', + authMiddleware, + courseMemberMiddleware, zValidator('param', ZCourseDownloadParam), zValidator('json', ZCourseDownloadContent), async (c) => { @@ -64,6 +70,7 @@ export const courseRouter = new Hono() .post( '/:courseId/clone', authMiddleware, + orgMemberMiddleware, zValidator('param', ZCourseCloneParam), zValidator('json', ZCourseClone), async (c) => { @@ -90,5 +97,5 @@ export const courseRouter = new Hono() } ) .route('/katex', katexRouter) - .route('/lesson', lessonRouter) + .route('/:courseId/lesson', lessonRouter) .route('/presign', presignRouter); diff --git a/apps/api/src/routes/course/lesson.ts b/apps/api/src/routes/course/lesson.ts index f2e4fc21..0c90586d 100644 --- a/apps/api/src/routes/course/lesson.ts +++ b/apps/api/src/routes/course/lesson.ts @@ -1,20 +1,26 @@ import { Hono } from '@api/utils/hono'; import { ZLessonDownloadContent } from '@cio/utils/validation/course'; +import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { generateLessonPdf } from '@api/utils/lesson'; import { zValidator } from '@hono/zod-validator'; -export const lessonRouter = new Hono().post('/download/pdf', zValidator('json', ZLessonDownloadContent), async (c) => { - const validatedData = c.req.valid('json'); +export const lessonRouter = new Hono().post( + '/download/pdf', + courseMemberMiddleware, + zValidator('json', ZLessonDownloadContent), + async (c) => { + const validatedData = c.req.valid('json'); - const pdfBuffer = await generateLessonPdf(validatedData); + const pdfBuffer = await generateLessonPdf(validatedData); - c.header('Content-Type', 'application/pdf'); - return c.body( - new ReadableStream({ - start(controller) { - controller.enqueue(pdfBuffer); - controller.close(); - } - }) - ); -}); + c.header('Content-Type', 'application/pdf'); + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(pdfBuffer); + controller.close(); + } + }) + ); + } +); diff --git a/apps/api/src/routes/dash/stats.ts b/apps/api/src/routes/dash/stats.ts index 9c8bd4ff..a8db6f69 100644 --- a/apps/api/src/routes/dash/stats.ts +++ b/apps/api/src/routes/dash/stats.ts @@ -3,11 +3,13 @@ import { ZDashStats } from '@cio/utils/validation/dash'; import { authMiddleware } from '@api/middlewares/auth'; import { getOrganisationAnalytics } from '@api/services/dash'; import { handleError } from '@api/utils/errors'; +import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { zValidator } from '@hono/zod-validator'; export const dashAnalyticsRouter = new Hono().get( '/stats', authMiddleware, + orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => { try { diff --git a/apps/api/src/routes/mail/mail.ts b/apps/api/src/routes/mail/mail.ts index 4ede1741..e9100f7a 100644 --- a/apps/api/src/routes/mail/mail.ts +++ b/apps/api/src/routes/mail/mail.ts @@ -1,10 +1,11 @@ import { Hono } from '@api/utils/hono'; import { ZDeliverEmail } from '@cio/utils/validation/mail'; +import { apiKeyMiddleware } from '@api/middlewares/api-key'; import { deliverEmail } from '@cio/email'; import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; -export const mailRouter = new Hono().post('/', zValidator('json', ZDeliverEmail), async (c) => { +export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => { const validatedData = c.req.valid('json'); try { diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 5e2b5e18..1c5b1ba1 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -34,6 +34,7 @@ import { authMiddleware } from '@api/middlewares/auth'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; import { handleError } from '@api/utils/errors'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; +import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { zValidator } from '@hono/zod-validator'; export const organizationRouter = new Hono() @@ -66,7 +67,7 @@ export const organizationRouter = new Hono() * Gets organization team members (non-students) * Requires authentication */ - .get('/:orgId/team', authMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => { + .get('/:orgId/team', authMiddleware, orgMemberMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => { try { const { orgId } = c.req.valid('param'); const team = await getOrgTeam(orgId); @@ -144,22 +145,28 @@ export const organizationRouter = new Hono() * Gets organization audience (students) * Requires authentication */ - .get('/:orgId/audience', authMiddleware, zValidator('param', ZGetOrganizationAudience), async (c) => { - try { - const { orgId } = c.req.valid('param'); - const audience = await getOrgAudience(orgId); + .get( + '/:orgId/audience', + authMiddleware, + orgMemberMiddleware, + zValidator('param', ZGetOrganizationAudience), + async (c) => { + try { + const { orgId } = c.req.valid('param'); + const audience = await getOrgAudience(orgId); - return c.json( - { - success: true, - data: audience - }, - 200 - ); - } catch (error) { - return handleError(c, error, 'Failed to fetch organization audience'); + return c.json( + { + success: true, + data: audience + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to fetch organization audience'); + } } - }) + ) /** * GET /organization/courses * Gets courses by organization siteName @@ -305,6 +312,7 @@ export const organizationRouter = new Hono() .put( '/:orgId', authMiddleware, + orgAdminMiddleware, zValidator('param', ZUpdateOrganizationParam), zValidator('json', ZUpdateOrganization), async (c) => { diff --git a/apps/api/src/services/community.ts b/apps/api/src/services/community.ts new file mode 100644 index 00000000..9e852644 --- /dev/null +++ b/apps/api/src/services/community.ts @@ -0,0 +1,204 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import type { TCommunityAnswer, TCommunityQuestion, TCourse } from '@db/types'; +import { + createCommunityQuestion, + deleteCommentById, + deleteCommentByQuestionId, + deleteQuestionByQuestionId, + editCommunityQuestion, + getCommunityQuestion, + getCommunityQuestions, + submitComment, + upvoteAnswer, + upvoteQuestion +} from '@cio/db/queries/community'; + +import { getCoursesByOrgId } from './organization'; + +export async function fetchCommunityQuestions(orgId: string): Promise[]> { + let discussions: Partial[] = []; + + let courses: TCourse[]; + + if (orgId) { + courses = await getCoursesByOrgId(orgId); + } else { + throw new AppError( + 'Organization not found for the given site name', + ErrorCodes.COMMUNITY_QUESTIONS_FETCH_FAILED, + 404 + ); + } + + try { + const courseIds = courses.map((course) => course.id); + discussions = await getCommunityQuestions(courseIds); + + return discussions; + } catch (error) { + console.error('Failed to fetch community questions:', error); + throw new AppError('Failed to fetch community questions', ErrorCodes.COMMUNITY_QUESTIONS_FETCH_FAILED, 500); + } +} + +export async function fetchCommunityQuestion({ slug }: Partial) { + try { + const result = await getCommunityQuestion(slug); + + if (!result) { + throw new AppError('Community question not found', ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND, 404); + } + + return { + id: result.id, + title: result.title, + body: result.body, + votes: result.votes, + createdAt: result.createdAt, + courseId: result.courseId, + courseTitle: result.course.title, + slug: result.slug, + authorId: result.author.id, + authorFullname: result.author.fullname, + authorAvatarUrl: result.author.avatarUrl, + organizationId: result.organizationId, + comments: result.comments + }; + } catch (error) { + console.error('Failed to fetch community question:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to fetch community question', ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND, 500); + } +} + +export async function createQuestion({ + title, + body, + courseId, + organizationId, + authorProfileId, + votes, + slug +}: Partial) { + try { + const result = await createCommunityQuestion({ + title, + body, + courseId, + organizationId, + authorProfileId, + votes, + slug + }); + + if (!result) { + throw new AppError('Failed to create community question', ErrorCodes.ADD_COMMUNITY_QUESTION_FAILED, 404); + } + + return { + id: result.id, + title: result.title, + body: result.body, + votes: result.votes, + createdAt: result.createdAt, + courseId: result.courseId, + courseTitle: result.title, + slug: result.slug, + authorId: result.authorId + }; + } catch (error) { + console.error('Failed to add community question:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to add community question', ErrorCodes.ADD_COMMUNITY_QUESTION_FAILED, 500); + } +} + +export async function editQuestion({ id, title, body, courseId }: Partial) { + try { + const result = await editCommunityQuestion({ id, title, body, courseId: courseId }); + + return result; + } catch (error) { + console.error('Failed to update community question:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to update community question', ErrorCodes.UPDATE_COMMUNITY_QUESTION_FAILED, 500); + } +} + +export async function createComment({ body, questionId, authorProfileId, votes }: Partial) { + try { + const result = await submitComment({ body, questionId, authorProfileId, votes }); + + if (!result) { + throw new AppError('Comment submission failed', ErrorCodes.COMMENT_SUBMISSION_FAILED, 404); + } + + return { + id: result.id, + body: result.body, + votes: result.votes, + createdAt: result.createdAt, + authorId: result.authorProfileId + }; + } catch (error) { + console.error('Failed to submit comment:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to submit comment', ErrorCodes.COMMENT_SUBMISSION_FAILED, 500); + } +} + +export async function upvote({ id, isQuestion }: { id: string | number; isQuestion: boolean }) { + try { + let result; + + if (isQuestion) { + result = await upvoteQuestion({ id: Number(id) }); + } else { + result = await upvoteAnswer({ id: String(id) }); + } + + return result; + } catch (error) { + console.error('Failed to upvote:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to upvote', ErrorCodes.UPVOTE_FAILED, 500); + } +} + +export async function deleteQuestion({ id }: Partial) { + try { + // Delete all comments first, then delete the question + await deleteCommentByQuestionId(id); + const result = await deleteQuestionByQuestionId(id); + + return result; + } catch (error) { + console.error('Failed to delete question:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to delete question', ErrorCodes.DELETE_QUESTION_FAILED, 500); + } +} + +export async function deleteComment({ id }: Partial) { + try { + const result = await deleteCommentById(id); + + return result; + } catch (error) { + console.error('Failed to delete comment:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to delete comment', ErrorCodes.DELETE_COMMENT_FAILED, 500); + } +} + +export async function deleteCommentsByQuestionId({ questionId }: Partial) { + try { + const result = await deleteCommentByQuestionId(questionId); + + return result; + } catch (error) { + console.error('Failed to delete comment:', error); + if (error instanceof AppError) throw error; + throw new AppError('Failed to delete comment', ErrorCodes.DELETE_COMMENT_FAILED, 500); + } +} diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts index bd596cda..f55b7ede 100644 --- a/apps/api/src/services/organization.ts +++ b/apps/api/src/services/organization.ts @@ -15,6 +15,7 @@ import { updateOrganizationPlan } from '@cio/db/queries/organization'; import { + getCoursesById, getCoursesBySiteName, getCoursesBySiteNameForSetup, getExercisesBySiteName, @@ -128,6 +129,23 @@ export async function getCoursesByOrgSiteName(siteName: string) { } } +/** + * Gets courses by organization orgId + * @param orgId - The organization orgId + * @returns Array of courses + */ +export async function getCoursesByOrgId(orgId: string) { + try { + return getCoursesById(orgId); + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch courses', + ErrorCodes.COURSES_FETCH_FAILED, + 500 + ); + } +} + /** * Gets setup data for an organization * @param siteName - The organization site name diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts index 418f121d..ce7b9e78 100644 --- a/apps/api/src/utils/errors.ts +++ b/apps/api/src/utils/errors.ts @@ -32,6 +32,16 @@ export const ErrorCodes = { 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', diff --git a/apps/dashboard/src/lib/features/community/api/community.svelte.ts b/apps/dashboard/src/lib/features/community/api/community.svelte.ts new file mode 100644 index 00000000..a7a640a2 --- /dev/null +++ b/apps/dashboard/src/lib/features/community/api/community.svelte.ts @@ -0,0 +1,459 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import type { + CommunityQuestionSuccess, + CommunityQuestionsSuccess, + CreateCommentResponse, + CreateCommunityQuestionResponse, + UpdateQuestionResponse, + UpvotePostResponse +} from '../utils/types'; +import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community'; +import { currentOrgPath, currentOrg as currentOrgStore } from '$lib/utils/store/org'; + +import type { Course } from '$lib/utils/types'; +import type { TCreateCommunityQuestion } from '@cio/utils/validation/community'; +import { courses as coursesStore } from '$features/course/utils/store'; +import { currentCommunityQuestion } from '../utils/store'; +import { fetchCourses } from '$lib/utils/services/courses'; +import generateSlug from '$lib/utils/functions/generateSlug'; +import { get } from 'svelte/store'; +import { goto } from '$app/navigation'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { profile as profileStore } from '$lib/utils/store/user'; +import { snackbar } from '$features/ui/snackbar/store'; + +/** + * API class for Organization Community + */ +class CommunityApi extends BaseApiWithErrors { + questions: CommunityQuestionsSuccess['data'] = $state([]); + question: CommunityQuestionSuccess['data'] | null = $state(null); + courses = $state([]); + private coursesFetched = $state(false); + isEditMode = $state(false); + isEditing = $state(false); + isCommenting = $state(false); + editContent = $state<{ + title: string; + body: string; + courseId: string; + }>({ + title: '', + body: '', + courseId: '' + }); + comment = $state(''); + deleteCommentState = $state<{ + shouldDelete: boolean; + commentId: string; + }>({ + shouldDelete: false, + commentId: '' + }); + answer = $state<{ + id: number; + body: string; + votes: number; + createdAt: string; + authorId: number; + }>({ + body: '', + votes: 0, + createdAt: '', + authorId: 0, + id: 0 + }); + + /** + * Fetches community questions for an organization + * Handles API call, success/error handling, and navigation + * @param orgId Organization ID + * @param isLMS Whether this is in LMS context (affects navigation path) + */ + async fetchCommunityQuestions({ orgId, isLMS = false }: { orgId: string; isLMS?: boolean }) { + await this.execute({ + requestFn: () => classroomio.community.$get({ query: { orgId } }), + logContext: 'fetching community questions', + onSuccess: (response) => { + this.questions = response.data; + }, + onError: (result) => { + console.error('Error loading community questions:', result); + const basePath = isLMS ? '/lms' : get(currentOrgPath); + goto(basePath); + } + }); + } + + /** + * Fetches a single community question by slug + * Handles API call, success/error handling, navigation, and store updates + * @param slug Question slug + * @param isLMS Whether this is in LMS context (affects navigation path and store updates) + */ + async fetchCommunityQuestion({ slug, isLMS = false }: { slug: string; isLMS?: boolean }) { + await this.execute<(typeof classroomio.community)[':slug']['$get']>({ + requestFn: () => classroomio.community[':slug'].$get({ param: { slug } }), + logContext: 'fetching community question', + onSuccess: (response) => { + if (response.data) { + this.question = response.data; + + currentCommunityQuestion.set({ title: response.data.title }); + } + }, + onError: (result) => { + console.error(`[${isLMS ? 'LMS' : 'ORG'}] Error loading community`, result); + const basePath = isLMS ? '/lms' : get(currentOrgPath); + goto(basePath); + } + }); + } + + /** + * Creates a new community question + * Handles validation, API call, success/error handling, and navigation + * @param fields User-provided fields (title, body, courseId) + * @param isLMS Whether this is in LMS context (affects navigation path) + */ + async createQuestion(fields: TCreateCommunityQuestion, isLMS: boolean = false) { + const result = ZCreateCommunityQuestion.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'community'); + return; + } + + const slug = generateSlug(fields.title); + const currentOrg = get(currentOrgStore); + const profile = get(profileStore); + + if (!currentOrg?.id || !profile?.id) { + snackbar.error('snackbar.community.error.missing_context'); + return; + } + + await this.execute({ + requestFn: () => + classroomio.community.$post({ + json: { + title: result.data.title, + body: result.data.body, + courseId: result.data.courseId || '', + organizationId: currentOrg.id, + authorProfileId: profile.id, + votes: 0, + slug + } + }), + logContext: 'creating community question', + onSuccess: (response) => { + const questionData = Array.isArray(response.data) ? response.data[0] : response.data; + const questionSlug = questionData?.slug || slug; + + // Show success message + snackbar.success('snackbar.community.success.question_submitted'); + + // Navigate to the question based on context + const basePath = isLMS ? '/lms' : get(currentOrgPath); + goto(`${basePath}/community/${questionSlug}`); + + // Mark as successful + this.success = true; + this.errors = {}; + }, + onError: (result) => { + console.error('Error creating question:', result); + snackbar.error('snackbar.community.error.try_again'); + if (typeof result !== 'string' && 'error' in result) { + this.handleValidationError(result); + } + } + }); + } + + /** + * Creates a comment for a community question + * Handles validation, API call, and success/error handling + * Uses this.comment state for the comment body + * @param questionId Question ID + * @param isLMS Whether this is in LMS context (affects store updates) + * @param onSuccess Optional callback to execute on successful comment creation + */ + async createComment(questionId: number, isLMS: boolean = false, onSuccess?: () => void) { + const profile = get(profileStore); + if (!profile?.id) { + snackbar.error('snackbar.community.error.missing_context'); + return; + } + + const result = ZCommunityComment.safeParse({ + body: this.comment, + authorProfileId: profile.id, + votes: 0 + }); + + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'community'); + return; + } + + this.isCommenting = true; + await this.execute({ + requestFn: () => + classroomio.community[':id'].comment.$post({ + param: { id: String(questionId) }, + // @ts-expect-error - the json type is not inferred correctly + json: { + body: result.data.body, + authorProfileId: result.data.authorProfileId, + votes: result.data.votes + } + }), + logContext: 'submitting comment', + onSuccess: (response) => { + if (response.data) { + this.answer = response.data; + + // Show success message + snackbar.success('snackbar.community.success.comment_submitted'); + + // Refetch the question to get updated comments with proper structure + if (this.question && this.question.slug) { + this.fetchCommunityQuestion({ slug: this.question.slug, isLMS }); + } + + // Call the onSuccess callback if provided + onSuccess?.(); + } + + // Mark as successful + this.success = true; + this.errors = {}; + this.isCommenting = false; + }, + onError: (result) => { + console.error('Error creating comment:', result); + snackbar.error('snackbar.community.error.comment_failed'); + if (typeof result !== 'string' && 'error' in result) { + this.handleValidationError(result); + } + this.isCommenting = false; + } + }); + } + + /** + * Upvotes a post (question or comment) + * Handles API call and updates local state with returned vote count + * @param id Post ID + * @param isQuestion Boolean indicating if it's a question + */ + async upvotePost({ id, isQuestion }: { id: number | string; isQuestion: boolean }) { + await this.execute({ + requestFn: () => + classroomio.community[':id'].upvote.$post({ + param: { id: id.toString() }, + // @ts-expect-error - the json type is not inferred correctly + json: { isQuestion } + }), + logContext: 'upvoting post', + onSuccess: (response) => { + // Update local question state with the vote count returned from backend + if (this.question && response.data) { + const newVoteCount = response.data.votes; + if (isQuestion && String(this.question.id) === String(id)) { + this.question.votes = newVoteCount; + } else if (!isQuestion) { + // Update comment votes + this.question.comments = this.question.comments.map((comment) => { + if (String(comment.id) === String(id)) { + return { ...comment, votes: newVoteCount }; + } + return comment; + }); + } + } + }, + onError: (result) => { + console.error('Error upvoting post:', result); + snackbar.error('snackbar.community.error.try_again'); + } + }); + } + + /** + * Updates a community question + * Handles validation, API call, and success/error handling + * @param questionId Question ID + * @param fields User-provided fields (title, body, courseId) + */ + async updateQuestion(questionId: number, fields: { title: string; body: string; courseId?: string }) { + const result = ZCommunityQuestionUpdate.safeParse({ + title: fields.title, + body: fields.body, + courseId: fields.courseId || '' + }); + + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'community'); + return; + } + + this.isEditing = true; + await this.execute({ + requestFn: () => + classroomio.community[':id'].$put({ + param: { id: String(questionId) }, + // @ts-expect-error - the json type is not inferred correctly + json: { + title: result.data.title, + body: result.data.body, + courseId: result.data.courseId + } + }), + logContext: 'updating community question', + onSuccess: () => { + if (this.question) { + this.question = { ...this.question, ...result.data }; + } + + console.log('this.question', this.question); + + // Show success message + snackbar.success('snackbar.community.success.question_updated'); + + // Exit edit mode on successful update + this.exitEditMode(); + + // Mark as successful + this.success = true; + this.errors = {}; + this.isEditing = false; + }, + onError: (result) => { + console.error('Error updating question:', result); + snackbar.error('snackbar.community.error.update_failed'); + if (typeof result !== 'string' && 'error' in result) { + this.handleValidationError(result); + } + this.isEditing = false; + } + }); + } + + /** + * Enters edit mode and initializes edit content with question data + * @param question Question data to populate edit form + */ + enterEditMode(question: { title: string; body: string; courseId?: string }) { + this.isEditMode = true; + this.editContent = { + title: question.title, + body: question.body, + courseId: question.courseId || '' + }; + } + + /** + * Exits edit mode and resets edit content + */ + exitEditMode() { + this.isEditMode = false; + this.editContent = { + title: '', + body: '', + courseId: '' + }; + } + + /** + * Deletes a question by its ID (including all comments) + * Handles API call, success/error handling, and navigation + * @param questionId Question ID + * @param isLMS Whether this is in LMS context (affects navigation path) + */ + async deleteQuestion(questionId: string, isLMS: boolean = false) { + await this.execute<(typeof classroomio.community)[':id']['$delete']>({ + requestFn: () => classroomio.community[':id'].$delete({ param: { id: questionId } }), + logContext: 'deleting community question', + onSuccess: () => { + // Show success message + snackbar.success('snackbar.community.success.success_delete'); + + // Navigate back to community page based on context + const basePath = isLMS ? '/lms' : get(currentOrgPath); + goto(`${basePath}/community`); + + // Mark as successful + this.success = true; + }, + onError: (result) => { + console.error('Error deleting question:', result); + snackbar.error('snackbar.community.error.delete_failed'); + } + }); + } + + /** + * Deletes a comment by its ID + * Handles API call and success/error handling + * Resets deleteComment state on success + * @param commentId Comment ID + */ + async deleteComment(commentId: string) { + await this.execute<(typeof classroomio.community)[':id']['comment']['$delete']>({ + requestFn: () => + classroomio.community[':id'].comment.$delete({ + param: { id: commentId } + }), + logContext: 'deleting community comment', + onSuccess: () => { + // Remove comment from local state + if (this.question) { + this.question.comments = this.question.comments.filter((comment) => String(comment.id) !== commentId); + } + + // Reset delete comment state + this.deleteCommentState.shouldDelete = false; + this.deleteCommentState.commentId = ''; + + // Show success message + snackbar.success('snackbar.community.success.success_delete'); + + // Mark as successful + this.success = true; + }, + onError: (result) => { + console.error('Error deleting comment:', result); + snackbar.error('snackbar.community.error.delete_failed'); + } + }); + } + + /** + * Fetches courses for the organization + * Checks store first, then fetches if needed + * @param userId User ID + * @param orgId Organization ID + */ + async fetchCoursesForOrg(userId: string | null, orgId: string) { + if (this.coursesFetched) return; + + // Check store first + const courses = get(coursesStore); + if (courses.length) { + this.courses = [...courses]; + this.coursesFetched = true; + return; + } + + // Fetch from API + const coursesResults = await fetchCourses(userId, orgId); + if (coursesResults) { + this.courses = coursesResults.allCourses || []; + this.coursesFetched = true; + } + } +} + +export const communityApi = new CommunityApi(); diff --git a/apps/dashboard/src/lib/features/community/components/community-comment-item.svelte b/apps/dashboard/src/lib/features/community/components/community-comment-item.svelte new file mode 100644 index 00000000..8f9cafe8 --- /dev/null +++ b/apps/dashboard/src/lib/features/community/components/community-comment-item.svelte @@ -0,0 +1,70 @@ + + +
+ +
+
+
+ + + {shortenName(comment.author?.fullname) || 'U'} + + +

{comment.author?.fullname}

+ +

+ {comment.createdAt ? calDateDiff(comment.createdAt) : ''} +

+
+ + {#if isValidAnswer} + + {/if} + + {#if isAuthorOrAdmin} + + + + {/if} +
+
+ {@html comment.body} +
+
+
diff --git a/apps/dashboard/src/lib/features/community/components/index.ts b/apps/dashboard/src/lib/features/community/components/index.ts index e845033f..24e6ee46 100644 --- a/apps/dashboard/src/lib/features/community/components/index.ts +++ b/apps/dashboard/src/lib/features/community/components/index.ts @@ -1,3 +1,4 @@ export { default as CommunityListLoader } from './community-list-loader.svelte'; export { default as CommunityDeleteModal } from './delete-modal.svelte'; export { default as AskCommunityButton } from './ask-community-button.svelte'; +export { default as CommunityCommentItem } from './community-comment-item.svelte'; diff --git a/apps/dashboard/src/lib/features/community/pages/ask-community.svelte b/apps/dashboard/src/lib/features/community/pages/ask-community.svelte new file mode 100644 index 00000000..5a210e02 --- /dev/null +++ b/apps/dashboard/src/lib/features/community/pages/ask-community.svelte @@ -0,0 +1,78 @@ + + +
+ +
+ + +

+ {fields.courseId + ? communityApi.courses.find((course) => course.id === fields.courseId)?.title + : $t('community.ask.select_course')} +

+
+ + {#each communityApi.courses as course} + {#if course.id} + {course.title} + {/if} + {/each} + +
+ {#if communityApi.errors.courseId} +

+ {communityApi.errors.courseId} +

+ {/if} +
+
+
+ (fields.body = content)} /> + + {#if communityApi.errors.body} +

{communityApi.errors.body}

+ {/if} +
+ +
+ +
diff --git a/apps/dashboard/src/lib/features/community/pages/community-question.svelte b/apps/dashboard/src/lib/features/community/pages/community-question.svelte new file mode 100644 index 00000000..217ee397 --- /dev/null +++ b/apps/dashboard/src/lib/features/community/pages/community-question.svelte @@ -0,0 +1,272 @@ + + + + {communityApi.question?.title || $t('community.ask.question')} + + + { + communityApi.deleteCommentState.shouldDelete = false; + communityApi.deleteCommentState.commentId = ''; + }} + onDelete={() => { + communityApi.deleteComment(communityApi.deleteCommentState.commentId); + }} +/> + + { + deleteQuestionState.shouldDelete = false; + deleteQuestionState.questionId = ''; + }} + onDelete={() => { + if (communityApi.question) { + communityApi.deleteQuestion(String(communityApi.question.id), isLMS); + } + }} +/> + +{#if !communityApi.question} +
+ + + +
+{:else} + + + + {#if communityApi.isEditMode} +
+ + + + + + {communityApi.editContent.courseId + ? communityApi.courses.find((course) => course.id === communityApi.editContent.courseId)?.title + : $t('community.ask.select_course')} + + + + {#each communityApi.courses as course} + {#if course.id} + {course.title} + {/if} + {/each} + + +
+ {:else} +
+ { + if (voted.question || !communityApi.question) return; + voted.question = true; + communityApi.upvotePost({ + id: Number(communityApi.question.id), + isQuestion: true + }); + }} + disabled={voted.question} + /> + {communityApi.question.title} +
+ {/if} +
+ {#if communityApi.question.authorId === $profile.id} + + + + {#if communityApi.isEditMode} + + {/if} + + {/if} +
+ + + {#snippet child()} +
+
+
+ + + {shortenName(communityApi.question?.authorFullname) || 'U'} + +

{communityApi.question?.authorFullname}

+

+ {communityApi.question?.createdAt ? calDateDiff(communityApi.question.createdAt) : ''} +

+
+ {#if communityApi.question?.authorId === $profile.id || $isOrgAdmin} + { + if (!communityApi.question) return; + + deleteQuestionState.shouldDelete = true; + deleteQuestionState.questionId = String(communityApi.question.id); + }} + > + + + {/if} +
+ {#if communityApi.isEditMode} +
+ (communityApi.editContent.body = content)} + /> +
+ {:else} +
+ {@html communityApi.question?.body} +
+ {/if} +
+ +
+ {pluralize($t('community.answers'), communityApi.question?.comments.length ?? 0, true)} +
+ +
+ {#each communityApi.question?.comments as commentItem} + { + if (voted.comment[commentItem.id]) return; + + voted.comment[commentItem.id] = true; + + communityApi.upvotePost({ + id: commentItem.id, + isQuestion: false + }); + }} + onDelete={() => { + console.log('deleting commentItem', commentItem); + communityApi.deleteCommentState.shouldDelete = true; + communityApi.deleteCommentState.commentId = commentItem.id; + }} + /> + {/each} +
+ +
+ +
+ (commentEditor = editor)} + /> + +
+ +
+
+ {/snippet} +
+
+{/if} diff --git a/apps/dashboard/src/lib/features/community/pages/community.svelte b/apps/dashboard/src/lib/features/community/pages/community.svelte index 84a756dd..87c57c8e 100644 --- a/apps/dashboard/src/lib/features/community/pages/community.svelte +++ b/apps/dashboard/src/lib/features/community/pages/community.svelte @@ -1,26 +1,23 @@ @@ -114,46 +58,50 @@

- {selectedId ? allCourses.find((course) => course.id === selectedId)?.title : $t('community.all')} + {selectedId + ? communityApi.courses.find((course) => course.id === selectedId)?.title || $t('community.all') + : $t('community.all')}

{$t('community.all')} - {#each allCourses as course} - {course.title} + {#each communityApi.courses as course} + {#if course.id} + {course.title} + {/if} {/each}
-{#if isLoading} +{#if communityApi.isLoading} {:else} - {#each filteredDiscussions as discussion} + {#each filteredQuestions as question} {#snippet child({ props })} - - + + - {discussion.title} + {question.title} - {discussion.author} asked {discussion.createdAt} + {question.authorFullname} asked {calDateDiff(question.createdAt)} - e.stopPropagation()}> + e.stopPropagation()}> - #{discussion.courseTitle} + #{question.courseTitle}
- {discussion.comments} + {question.comments}
diff --git a/apps/dashboard/src/lib/features/community/pages/index.ts b/apps/dashboard/src/lib/features/community/pages/index.ts index 54cd46b3..fca3602d 100644 --- a/apps/dashboard/src/lib/features/community/pages/index.ts +++ b/apps/dashboard/src/lib/features/community/pages/index.ts @@ -1 +1,3 @@ export { default as CommunityPage } from './community.svelte'; +export { default as AskCommunityPage } from './ask-community.svelte'; +export { default as CommunityQuestionPage } from './community-question.svelte'; diff --git a/apps/dashboard/src/lib/features/community/utils/types.ts b/apps/dashboard/src/lib/features/community/utils/types.ts new file mode 100644 index 00000000..8676c6ed --- /dev/null +++ b/apps/dashboard/src/lib/features/community/utils/types.ts @@ -0,0 +1,27 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +export type GetCommunityQuestionsResponse = typeof classroomio.community.$get; +export type CommunityQuestionsResponse = InferResponseType | null; +export type CommunityQuestionsSuccess = Extract, { success: true }>; +export type CommunityQuestionData = CommunityQuestionsSuccess['data']; + +export type GetCommunityQuestionResponse = (typeof classroomio.community)[':slug']['$get']; +export type CommunityQuestionResponse = InferResponseType | null; +export type CommunityQuestionSuccess = Extract, { success: true }>; + +export type CreateCommentResponse = typeof classroomio.community.$post; +export type CreateCommentSuccess = Extract, { success: true }>; +export type CreateCommentData = CreateCommentSuccess['data']; + +export type UpvotePostResponse = (typeof classroomio.community)[':id']['upvote']['$post']; +export type UpvotePostSuccess = Extract, { success: true }>; +export type UpvotePostData = UpvotePostSuccess['data']; + +export type CreateCommunityQuestionResponse = typeof classroomio.community.$post; +export type CreateCommunityQuestionSuccess = Extract< + InferResponseType, + { success: true } +>; +export type UpdateQuestionResponse = (typeof classroomio.community)[':id']['$put']; +export type UpdateQuestionSuccess = Extract, { success: true }>; +export type UpdateQuestionData = UpdateQuestionSuccess['data']; diff --git a/apps/dashboard/src/lib/features/ui/text-editor.svelte b/apps/dashboard/src/lib/features/ui/text-editor.svelte index b2582b61..bc84b7b9 100644 --- a/apps/dashboard/src/lib/features/ui/text-editor.svelte +++ b/apps/dashboard/src/lib/features/ui/text-editor.svelte @@ -27,7 +27,7 @@ } let { - content, + content = $bindable(''), showToolBar = true, editable = true, enablePersistence = false, @@ -43,7 +43,7 @@ - diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 0e43fda2..6a8a9758 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -1538,6 +1538,7 @@ }, "success": { + "question_updated": "Question Updated!", "comment_submitted": "Comment Submitted!", "success_delete": "Deleted successfully", "question_submitted": "Question Submitted!" diff --git a/apps/dashboard/src/routes/+page.svelte b/apps/dashboard/src/routes/+page.svelte index a309f67f..6ecc2821 100644 --- a/apps/dashboard/src/routes/+page.svelte +++ b/apps/dashboard/src/routes/+page.svelte @@ -13,7 +13,7 @@ - ClassroomIO - The Coure Platform That’s Actually Easy To Use + ClassroomIO - The Course Platform That’s Actually Easy To Use {#if !isLoadingApp && appInitApi.error} diff --git a/apps/dashboard/src/routes/lms/community/[slug]/+page.svelte b/apps/dashboard/src/routes/lms/community/[slug]/+page.svelte index d08b33f4..4369da58 100644 --- a/apps/dashboard/src/routes/lms/community/[slug]/+page.svelte +++ b/apps/dashboard/src/routes/lms/community/[slug]/+page.svelte @@ -1,553 +1,11 @@ - - {question?.title || 'Question'} + Question - ClassroomIO - { - deleteQuestion.shouldDelete = false; - deleteQuestion.questionId = ''; - }} - onDelete={() => handleDelete(true)} - isQuestion={true} -/> - -{#if !question} -
- - - -
-{:else} - {@const currentQuestion = question} - - - - {#if isEditMode} -
- - - -

- {editContent.courseId - ? fetchedCourses.find((course) => course.id === editContent.courseId)?.title - : $t('community.ask.select_course')} -

-
- - {#each fetchedCourses as course} - {#if course.id} - {course.title} - {/if} - {/each} - -
-
- {:else} -
- upvoteQuestion('question')} disabled={voted.question} /> - {currentQuestion.title} -
- {/if} -
- {#if currentQuestion.author.id === $profile.id} - - - {#if isEditMode} - - {/if} - - {/if} -
- - {#snippet child()} -
-
-
- - - {shortenName(currentQuestion.author.name) || 'U'} - -

{currentQuestion.author.name}

-

- {currentQuestion.createdAt} -

-
- {#if currentQuestion.author.id === $profile.id || $isOrgAdmin} - { - deleteQuestion.shouldDelete = true; - deleteQuestion.questionId = currentQuestion.id; - }} - > - - - {/if} -
- {#if isEditMode && editorInstance} -
- (editContent.body = content)} - /> -
- {:else} -
- {@html currentQuestion.body} -
- {/if} -
- -
- {pluralize($t('community.answers'), currentQuestion.totalComments, true)} -
- - {#each currentQuestion.comments as comment} -
- upvoteQuestion('comment', comment.id)} - disabled={voted.comment[comment.id]} - /> -
-
-
- - - {shortenName(comment.name) || 'U'} - -

{comment.name}

-

- {comment.createdAt} -

-
- - {#if isValidAnswer} - - {/if} - - {#if comment.authorId === $profile.id || $isOrgAdmin} - { - deleteComment.shouldDelete = true; - deleteComment.commentId = comment.id; - }} - > - - - {/if} -
-
- {@html comment.comment} -
-
-
- {/each} - -
- -
- {#if !editorInstance} - (comment = content)} /> - {/if} - -
- -
-
- {/snippet} -
-
-{/if} + diff --git a/apps/dashboard/src/routes/lms/community/ask/+page.svelte b/apps/dashboard/src/routes/lms/community/ask/+page.svelte index 485edd04..a9ddc45d 100644 --- a/apps/dashboard/src/routes/lms/community/ask/+page.svelte +++ b/apps/dashboard/src/routes/lms/community/ask/+page.svelte @@ -1,152 +1,22 @@ - Ask the Community - ClassroomIO - + {$t('community.ask.ask_the')} - - - {#snippet child()} -
- -
- - -

- {fields.courseId - ? fetchedCourses.find((course) => course.id === fields.courseId)?.title - : $t('community.ask.select_course')} -

-
- - {#each fetchedCourses as course} - {#if course.id} - {course.title} - {/if} - {/each} - -
- {#if errors.courseId} -

- {errors.courseId} -

- {/if} -
-
-
- (fields.body = content)} - /> - - {#if errors.body} -

{errors.body}

- {/if} -
+ {/snippet}
diff --git a/apps/dashboard/src/routes/org/[slug]/community/[slug]/+page.svelte b/apps/dashboard/src/routes/org/[slug]/community/[slug]/+page.svelte index 2f582b11..b0a51ddf 100644 --- a/apps/dashboard/src/routes/org/[slug]/community/[slug]/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/community/[slug]/+page.svelte @@ -1,546 +1,11 @@ - - {question?.title || $t('community.ask.question')} + Question - ClassroomIO - { - deleteComment.shouldDelete = false; - deleteComment.commentId = ''; - }} - onDelete={() => handleDelete(false)} -/> - -{#if !question} -
- - - -
-{:else} - - - - {#if isEditMode} -
- - - -

- {editContent.courseId - ? fetchedCourses.find((course) => course.id === editContent.courseId)?.title - : $t('community.ask.select_course')} -

-
- - {#each fetchedCourses as course} - {course.title} - {/each} - -
-
- {:else} -
- upvoteQuestion('question')} disabled={voted.question} /> - {question.title} -
- {/if} -
- {#if question.author.id === $profile.id} - - - {#if isEditMode} - - {/if} - - {/if} -
- - {#snippet child()} -
-
-
- - - {shortenName(question.author.name) || 'U'} - -

{question.author.name}

-

- {question.createdAt} -

-
- {#if question.author.id === $profile.id || $isOrgAdmin} - { - if (!question) return; - - deleteQuestion.shouldDelete = true; - deleteQuestion.questionId = question.id; - }} - > - - - {/if} -
- {#if isEditMode && editorInstance} -
- (editContent.body = content)} - /> -
- {:else} -
- {@html question.body} -
- {/if} -
- -
- {pluralize($t('community.answers'), question.totalComments, true)} -
- - {#each question.comments as comment} -
- upvoteQuestion('comment', comment.id)} - disabled={voted.comment[comment.id]} - /> -
-
-
- - - {shortenName(comment.name) || 'U'} - -

{comment.name}

-

- {comment.createdAt} -

-
- - {#if isValidAnswer} - - {/if} - - {#if comment.authorId === $profile.id || $isOrgAdmin} - { - deleteComment.shouldDelete = true; - deleteComment.commentId = comment.id; - }} - > - - - {/if} -
-
- {@html comment.comment} -
-
-
- {/each} - -
- -
- {#if !editorInstance} - (comment = content)} /> - {/if} - -
- -
-
- {/snippet} -
-
-{/if} + diff --git a/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte b/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte index 6ad0bb9a..29265e58 100644 --- a/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/community/ask/+page.svelte @@ -1,152 +1,22 @@ - Ask the Community - ClassroomIO - + {$t('community.ask.ask_the')} - - - {#snippet child()} -
- -
- - -

- {fields.courseId - ? fetchedCourses.find((course) => course.id === fields.courseId)?.title - : $t('community.ask.select_course')} -

-
- - {#each fetchedCourses as course} - {#if course.id} - {course.title} - {/if} - {/each} - -
- {#if errors.courseId} -

- {errors.courseId} -

- {/if} -
-
-
- (fields.body = content)} - /> - - {#if errors.body} -

{errors.body}

- {/if} -
+ {/snippet}
diff --git a/package.json b/package.json index bbe90c37..afe62b8e 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,9 @@ "clean:pnpm": "pnpm clean && pnpm store prune", "format": "prettier . --write", "release": "standard-version", - "dashboard:dev": "pnpm dev --filter=@cio/dashboard", - "dashboard:dev-full": "pnpm dev --filter=@cio/dashboard --filter=@cio/ui --filter=@cio/db --filter=@cio/utils", + "dashboard:dev": "pnpm dev --filter=@cio/dashboard --filter=@cio/ui --filter=@cio/db --filter=@cio/utils", "dashboard:start": "pnpm start --filter @cio/dashboard", - "api:dev": "pnpm dev --filter=@cio/api", - "api:dev-full": "pnpm dev --filter=@cio/api --filter=@cio/email --filter=@cio/db --filter=@cio/utils", + "api:dev": "pnpm dev --filter=@cio/api --filter=@cio/email --filter=@cio/db --filter=@cio/utils", "api:start": "pnpm start --filter @cio/api", "supabase:push": "pnpx supabase link --project-ref $PROJECT_ID && pnpx supabase db push --include-all", "storybook:dev": "pnpm dev --filter=@cio/storybook --filter=@cio/ui" diff --git a/packages/db/src/queries/community/community.ts b/packages/db/src/queries/community/community.ts new file mode 100644 index 00000000..d370c411 --- /dev/null +++ b/packages/db/src/queries/community/community.ts @@ -0,0 +1,254 @@ +import { TCommunityAnswer, TCommunityQuestion, TNewCommunityQuestion } from '@db/types'; +import { + communityAnswer, + communityQuestion, + course, + db, + desc, + eq, + inArray, + organization, + profile, + sql +} from '@db/drizzle'; + +export async function getCommunityQuestions(courseIdsFilter: string[]) { + const commentsCount = sql`COUNT(${communityAnswer.id})`; + + return db + .select({ + organizationId: communityQuestion.organizationId, + courseId: communityQuestion.courseId, + title: communityQuestion.title, + votes: communityQuestion.votes, + createdAt: communityQuestion.createdAt, + slug: communityQuestion.slug, + comments: commentsCount, + authorFullname: profile.fullname, + courseTitle: course.title + }) + .from(communityQuestion) + .innerJoin(course, eq(communityQuestion.courseId, course.id)) + .leftJoin(communityAnswer, eq(communityAnswer.questionId, communityQuestion.id)) + .leftJoin(profile, eq(profile.id, communityQuestion.authorProfileId)) + .where(inArray(communityQuestion.courseId, courseIdsFilter)) + .groupBy( + communityQuestion.organizationId, + communityQuestion.courseId, + communityQuestion.title, + communityQuestion.votes, + communityQuestion.createdAt, + communityQuestion.slug, + profile.fullname, + course.title + ) + .orderBy(desc(communityQuestion.createdAt)); +} + +export async function getCommunityQuestion(slug: string) { + const questionRows = await db + .select({ + id: communityQuestion.id, + title: communityQuestion.title, + body: communityQuestion.body, + votes: communityQuestion.votes, + createdAt: communityQuestion.createdAt, + courseId: communityQuestion.courseId, + slug: communityQuestion.slug, + authorId: profile.id, + authorName: profile.fullname, + authorAvatar: profile.avatarUrl, + courseTitle: course.title, + organizationId: communityQuestion.organizationId + }) + .from(communityQuestion) + .innerJoin(course, eq(course.id, communityQuestion.courseId)) + .leftJoin(profile, eq(profile.id, communityQuestion.authorProfileId)) + .where(eq(communityQuestion.slug, slug)) + .limit(1); + + if (questionRows.length === 0) return null; + + const q = questionRows[0]; + + const commentsRows = await db + .select({ + id: communityAnswer.id, + body: communityAnswer.body, + votes: communityAnswer.votes, + createdAt: communityAnswer.createdAt, + + authorId: profile.id, + authorName: profile.fullname, + authorAvatar: profile.avatarUrl + }) + .from(communityAnswer) + .leftJoin(profile, eq(profile.id, communityAnswer.authorProfileId)) + .where(eq(communityAnswer.questionId, q.id)) + .orderBy(communityAnswer.createdAt); + + return { + id: q.id, + title: q.title, + body: q.body, + votes: q.votes, + createdAt: q.createdAt, + courseId: q.courseId, + slug: q.slug, + organizationId: q.organizationId, + + author: { + id: q.authorId, + fullname: q.authorName, + avatarUrl: q.authorAvatar + }, + + course: { + title: q.courseTitle + }, + + comments: commentsRows.map((c) => ({ + id: c.id, + body: c.body, + votes: c.votes, + createdAt: c.createdAt, + author: { + id: c.authorId, + fullname: c.authorName, + avatarUrl: c.authorAvatar + } + })) + }; +} + +export async function submitComment({ + body, + questionId, + authorProfileId, + votes +}: { + body: string; + questionId: number; + authorProfileId: string; + votes: number; +}) { + const response = await db + .insert(communityAnswer) + .values({ + body, + questionId, + authorProfileId, + votes + }) + .returning(); + + return response[0]; +} + +export async function upvoteQuestion({ id }: Partial) { + if (!id) throw new Error('Question ID is required for upvoting'); + + const result = await db + .update(communityQuestion) + .set({ votes: sql`${communityQuestion.votes} + 1` }) + .where(eq(communityQuestion.id, id)) + .returning({ votes: communityQuestion.votes }); + + return result[0]; +} + +export async function upvoteAnswer({ id }: Partial) { + if (!id) throw new Error('Answer ID is required for upvoting'); + + const result = await db + .update(communityAnswer) + .set({ votes: sql`${communityAnswer.votes} + 1` }) + .where(eq(communityAnswer.id, id)) + .returning({ votes: communityAnswer.votes }); + + return result[0]; +} + +export async function editCommunityQuestion({ id, title, body, courseId }: TNewCommunityQuestion) { + if (!id) throw new Error('Question ID is required for editing'); + + await db.update(communityQuestion).set({ title, body, courseId }).where(eq(communityQuestion.id, id)); +} + +export async function deleteCommentById(id: string) { + await db.delete(communityAnswer).where(eq(communityAnswer.id, id)); +} + +export async function deleteCommentByQuestionId(questionId: number) { + await db.delete(communityAnswer).where(eq(communityAnswer.questionId, questionId)); +} + +export async function deleteQuestionByQuestionId(questionId: number) { + await db.delete(communityQuestion).where(eq(communityQuestion.id, questionId)); +} + +export async function createCommunityQuestion({ + title, + body, + organizationId, + authorProfileId, + slug, + courseId +}: TNewCommunityQuestion) { + const result = await db + .insert(communityQuestion) + .values({ + title, + body, + organizationId, + authorProfileId, + votes: 0, + slug, + courseId + }) + .returning(); + + return result[0]; +} + +/** + * Gets question author and course information for authorization checks + * @param questionId Question ID + * @returns Question with author ID and course ID, or null if not found + */ +export async function getQuestionAuthorAndCourse(questionId: number | string) { + const result = await db + .select({ + id: communityQuestion.id, + authorId: communityQuestion.authorProfileId, + courseId: communityQuestion.courseId, + organizationId: communityQuestion.organizationId + }) + .from(communityQuestion) + .where(eq(communityQuestion.id, Number(questionId))) + .limit(1); + + return result.length > 0 ? result[0] : null; +} + +/** + * Gets comment author and course information for authorization checks + * @param commentId Comment ID + * @returns Comment with author ID, question ID, and course ID, or null if not found + */ +export async function getCommentAuthorAndCourse(commentId: string) { + const result = await db + .select({ + id: communityAnswer.id, + authorId: communityAnswer.authorProfileId, + questionId: communityAnswer.questionId, + courseId: communityQuestion.courseId, + organizationId: communityQuestion.organizationId + }) + .from(communityAnswer) + .innerJoin(communityQuestion, eq(communityAnswer.questionId, communityQuestion.id)) + .where(eq(communityAnswer.id, commentId)) + .limit(1); + + return result.length > 0 ? result[0] : null; +} diff --git a/packages/db/src/queries/community/index.ts b/packages/db/src/queries/community/index.ts new file mode 100644 index 00000000..12bb9613 --- /dev/null +++ b/packages/db/src/queries/community/index.ts @@ -0,0 +1 @@ +export * from './community'; diff --git a/packages/db/src/queries/course/course.ts b/packages/db/src/queries/course/course.ts index 7c93e61e..73bc44b2 100644 --- a/packages/db/src/queries/course/course.ts +++ b/packages/db/src/queries/course/course.ts @@ -2,7 +2,7 @@ import * as schema from '@db/schema'; import { and, eq, sql } from 'drizzle-orm'; -import { TNewCourse } from '@db/types'; +import { TCourse, TNewCourse } from '@db/types'; import { db } from '@db/drizzle'; /** @@ -10,7 +10,7 @@ import { db } from '@db/drizzle'; * @param siteName Organization site name * @returns Array of courses with lesson counts and organization info */ -export const getCoursesBySiteName = async (siteName: string) => { +export const getCoursesBySiteName = async (siteName: string): Promise => { try { const result = await db .select({ @@ -56,6 +56,53 @@ export const getCoursesBySiteName = async (siteName: string) => { } }; +/** + * Gets courses by organization orgId + * @param orgId Organization orgId + * @returns Array of courses with lesson counts and organization info + */ +export const getCoursesById = async (orgId: string): Promise => { + try { + const result = await db + .select({ + course: schema.course, + organization: { + id: schema.organization.id, + name: schema.organization.name, + siteName: schema.organization.siteName, + avatarUrl: schema.organization.avatarUrl + }, + lessonCount: sql`COUNT(${schema.lesson.id})`.as('lesson_count') + }) + .from(schema.course) + .innerJoin(schema.group, eq(schema.course.groupId, schema.group.id)) + .innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id)) + .leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId)) + .where( + and(eq(schema.organization.id, orgId), eq(schema.course.status, 'ACTIVE'), eq(schema.course.isPublished, true)) + ) + .groupBy( + schema.course.id, + schema.organization.id, + schema.organization.name, + schema.organization.siteName, + schema.organization.avatarUrl + ); + + return result.map((row) => ({ + ...row.course, + lessons: [{ count: Number(row.lessonCount) }], + group: { + organization: row.organization + } + })); + } catch (error) { + throw new Error( + `Failed to get courses by org ID "${orgId}": ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +}; + /** * Gets lessons by organization siteName * @param siteName Organization site name diff --git a/packages/db/src/queries/group/group.ts b/packages/db/src/queries/group/group.ts index 83d4b62a..c5f752e5 100644 --- a/packages/db/src/queries/group/group.ts +++ b/packages/db/src/queries/group/group.ts @@ -1,7 +1,11 @@ -import { db } from '@db/drizzle'; -import { TNewGroup, TNewGroupmember } from '@db/types'; import * as schema from '@db/schema'; +import { TNewGroup, TNewGroupmember } from '@db/types'; +import { and, eq, or } from 'drizzle-orm'; + +import { ROLE } from '@cio/utils/constants'; +import { db } from '@db/drizzle'; + export async function createGroup(values: TNewGroup) { try { return db.insert(schema.group).values(values).returning(); @@ -17,3 +21,60 @@ export async function addGroupMember(values: TNewGroupmember) { throw new Error(`Failed to add group member: ${error instanceof Error ? error.message : 'Unknown error'}`); } } + +/** + * Checks if a user is a member of a course's group (any role) + * @param courseId Course ID + * @param profileId Profile ID to check + * @returns True if user is a member of the course's group, false otherwise + */ +export const isUserCourseMember = async (courseId: string, profileId: string): Promise => { + const result = await db + .select({ roleId: schema.groupmember.roleId }) + .from(schema.groupmember) + .innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId)) + .where(and(eq(schema.course.id, courseId), eq(schema.groupmember.profileId, profileId))) + .limit(1); + + return result.length > 0; +}; + +/** + * Gets a user's role in a course's group + * @param courseId Course ID + * @param profileId Profile ID to check + * @returns Role ID if user is a member, null otherwise + */ +export const getUserCourseRole = async (courseId: string, profileId: string): Promise => { + const result = await db + .select({ roleId: schema.groupmember.roleId }) + .from(schema.groupmember) + .innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId)) + .where(and(eq(schema.course.id, courseId), eq(schema.groupmember.profileId, profileId))) + .limit(1); + + return result.length > 0 ? Number(result[0].roleId) : null; +}; + +/** + * Checks if a user is a team member (ADMIN or TUTOR) in a course's group + * @param courseId Course ID + * @param profileId Profile ID to check + * @returns True if user is ADMIN or TUTOR in the course's group, false otherwise + */ +export const isUserCourseTeamMember = async (courseId: string, profileId: string): Promise => { + const result = await db + .select({ roleId: schema.groupmember.roleId }) + .from(schema.groupmember) + .innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId)) + .where( + and( + eq(schema.course.id, courseId), + eq(schema.groupmember.profileId, profileId), + or(eq(schema.groupmember.roleId, ROLE.ADMIN), eq(schema.groupmember.roleId, ROLE.TUTOR)) + ) + ) + .limit(1); + + return result.length > 0; +}; diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index e1244684..bc850eae 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -243,6 +243,44 @@ export const isUserOrgAdmin = async (orgId: string, profileId: string): Promise< return result.length > 0; }; +/** + * Checks if a user is a member of an organization (any role) + * @param orgId Organization ID + * @param profileId Profile ID to check + * @returns True if user is a member, false otherwise + */ +export const isUserOrgMember = async (orgId: string, profileId: string): Promise => { + const result = await db + .select({ roleId: schema.organizationmember.roleId }) + .from(schema.organizationmember) + .where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, profileId))) + .limit(1); + + return result.length > 0; +}; + +/** + * Checks if a user is a team member (ADMIN or TUTOR) of an organization + * @param orgId Organization ID + * @param profileId Profile ID to check + * @returns True if user is ADMIN or TUTOR, false otherwise + */ +export const isUserOrgTeamMember = async (orgId: string, profileId: string): Promise => { + const result = await db + .select({ roleId: schema.organizationmember.roleId }) + .from(schema.organizationmember) + .where( + and( + eq(schema.organizationmember.organizationId, orgId), + eq(schema.organizationmember.profileId, profileId), + or(eq(schema.organizationmember.roleId, ROLE.ADMIN), eq(schema.organizationmember.roleId, ROLE.TUTOR)) + ) + ) + .limit(1); + + return result.length > 0; +}; + /** * Gets organization team members (non-students) * @param orgId Organization ID diff --git a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte index 1ba1d986..690b01a2 100644 --- a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte +++ b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte @@ -11,5 +11,5 @@ ClassroomIO -

The Coure Platform That’s Actually Easy To Use

+

The Course Platform That’s Actually Easy To Use

diff --git a/packages/utils/src/validation/community/community.ts b/packages/utils/src/validation/community/community.ts new file mode 100644 index 00000000..1cb4a992 --- /dev/null +++ b/packages/utils/src/validation/community/community.ts @@ -0,0 +1,69 @@ +import * as z from 'zod'; + +export const ZCommunityQuestions = z.object({ + orgId: z.string().min(1, 'orgId is required') +}); + +export const ZCommunityQuestion = z.object({ + slug: z.string().min(1, 'slug is required') +}); + +export const ZNewCommunityQuestion = z.object({ + slug: z.string().min(1, 'slug is required'), + body: z.string().min(1, 'body is required'), + title: z.string().min(1, 'title is required'), + courseId: z.string().min(1, 'courseId is required'), + organizationId: z.string().min(1, 'organizationId is required'), + authorProfileId: z.string().min(1, 'authorProfileId is required'), + votes: z.number().optional() +}); + +export const ZCreateCommunityQuestion = z.object({ + body: z.string().min(1), + title: z.string().min(1), + courseId: z.string().optional() +}); + +export const ZGetCommunity = z.object({ + id: z.coerce.number() +}); + +export const ZCommunityQuestionUpdate = z.object({ + title: z.string().min(1, 'title is required'), + body: z.string().min(1, 'body is required'), + courseId: z.string().min(1, 'courseId is required') +}); + +// --- comment +export const ZCommunityCommentDelete = z.object({ + id: z.string().min(1, 'id is required') +}); + +export const ZGetCommunityCommentQuestionId = z.object({ + questionId: z.coerce.number() +}); + +export const ZCommunityComment = z.object({ + body: z.string().min(1, 'body is required'), + authorProfileId: z.string().min(1, 'authorProfileId is required'), + votes: z.number().optional() +}); + +export const ZUpvotePost = z.object({ + isQuestion: z.boolean() +}); + +export const ZUpvotePostParam = z.object({ + id: z.union([z.string().min(1, 'id is required'), z.number().min(1, 'id is required')]) +}); + +export type TNewCommunityQuestion = z.infer; +export type TCreateCommunityQuestion = z.infer; +export type TCommunityQuestions = z.infer; +export type TCommunityQuestion = z.infer; + +export type TCommunityComment = z.infer; +export type TUpvotePost = z.infer; + +export type TCommunityCommentDelete = z.infer; +export type TGetCommunity = z.infer; diff --git a/packages/utils/src/validation/community/index.ts b/packages/utils/src/validation/community/index.ts new file mode 100644 index 00000000..12bb9613 --- /dev/null +++ b/packages/utils/src/validation/community/index.ts @@ -0,0 +1 @@ +export * from './community';