feat: move community supabase request to api layer (#612)
* feat: move community supabase request to api layer * chore: pr review checkpoint * chore: PR fixes * feat: implement role-based authorization middleware and align the community features with the developer guide. - Added role-based authorization middleware for organization and course membership checks, including `orgMemberMiddleware`, `orgTeamMemberMiddleware`, and `courseMemberMiddleware`. - Introduced route-specific middleware for comment and question authorization, ensuring only authors or team members can modify content. - Updated community routes to utilize the new middleware for enhanced security and access control. - Refactored community services to streamline upvote functionality and improve error handling. - Enhanced UI components for community interactions, including new comment and question pages, and improved state management for user actions. - Updated documentation to reflect new middleware usage and best practices for authorization in the API layer. * feat: enhance course and organization routes with middleware for improved access control - Added `courseMemberMiddleware` to course download and clone routes to enforce membership checks. - Updated lesson route to include `courseMemberMiddleware` for PDF downloads. - Integrated `orgMemberMiddleware` into organization routes for team and audience access, ensuring only authorized members can retrieve data. - Refactored lesson and organization routes for consistency and clarity in middleware usage. * chore: streamline dashboard and API development scripts in package.json - Consolidated dashboard and API development commands to include all relevant filters in a single line for improved clarity and ease of use. - Removed redundant full command variants to simplify the script section. --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
9fb6c7cbaa
commit
bc9e3b41ae
+450
-117
@@ -826,6 +826,107 @@ import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
|||||||
- Use `apiKeyMiddleware` for API-key-only endpoints
|
- Use `apiKeyMiddleware` for API-key-only endpoints
|
||||||
- Use `authOrApiKeyMiddleware` for endpoints that accept both user sessions and API keys
|
- 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
|
### Complete Route Example
|
||||||
|
|
||||||
Routes should be thin - they handle HTTP concerns and delegate to services:
|
Routes should be thin - they handle HTTP concerns and delegate to services:
|
||||||
@@ -1699,10 +1800,117 @@ try {
|
|||||||
|
|
||||||
### Complete Frontend Example
|
### 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<typeof classroomio.course.create.$post>({
|
||||||
|
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
|
```typescript
|
||||||
// apps/dashboard/src/routes/course/+page.svelte
|
// apps/dashboard/src/routes/course/+page.svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { courseApi } from '$features/course/api/course.svelte';
|
||||||
|
|
||||||
|
let fields = $state({ title: '', slug: '', description: '' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={() => courseApi.createCourse(fields)}>
|
||||||
|
<div>
|
||||||
|
<label for="title">Course Title</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
bind:value={fields.title}
|
||||||
|
class:error={courseApi.errors.title}
|
||||||
|
/>
|
||||||
|
{#if courseApi.errors.title}
|
||||||
|
<span class="error-message">{courseApi.errors.title}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="slug">Course Slug</label>
|
||||||
|
<input
|
||||||
|
id="slug"
|
||||||
|
bind:value={fields.slug}
|
||||||
|
class:error={courseApi.errors.slug}
|
||||||
|
/>
|
||||||
|
{#if courseApi.errors.slug}
|
||||||
|
<span class="error-message">{courseApi.errors.slug}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={courseApi.isLoading}>
|
||||||
|
{courseApi.isLoading ? 'Creating...' : 'Create Course'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { classroomio } from '$lib/utils/services/api';
|
import { classroomio } from '$lib/utils/services/api';
|
||||||
import { ZCourseCreate } from '@cio/utils/validation/course';
|
import { ZCourseCreate } from '@cio/utils/validation/course';
|
||||||
@@ -1714,30 +1922,27 @@ Example with **client-side validation** before API call:
|
|||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
// ❌ Validation in component
|
||||||
loading = true;
|
loading = true;
|
||||||
errors = {};
|
errors = {};
|
||||||
|
|
||||||
// Client-side validation with entity-specific translations
|
|
||||||
const result = ZCourseCreate.safeParse(fields);
|
const result = ZCourseCreate.safeParse(fields);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Map validation errors to translated messages
|
|
||||||
errors = mapZodErrorsToTranslations(result.error, 'course');
|
errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||||
// errors.title = "Course title is required"
|
|
||||||
// errors.slug = "Course slug is required"
|
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ❌ Manual API call
|
||||||
const response = await classroomio.course.create.$post({
|
const response = await classroomio.course.create.$post({
|
||||||
json: result.data // Send validated data
|
json: result.data
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// ❌ Manual error handling
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
// Handle API error (e.g., duplicate slug)
|
|
||||||
if (data.field) {
|
if (data.field) {
|
||||||
errors[data.field] = data.error;
|
errors[data.field] = data.error;
|
||||||
} else {
|
} else {
|
||||||
@@ -1747,7 +1952,7 @@ Example with **client-side validation** before API call:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - redirect to course
|
// ❌ Navigation in component
|
||||||
const { course } = data.data;
|
const { course } = data.data;
|
||||||
await goto(`/courses/${course.slug}`);
|
await goto(`/courses/${course.slug}`);
|
||||||
|
|
||||||
@@ -1759,74 +1964,117 @@ Example with **client-side validation** before API call:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label for="title">Course Title</label>
|
|
||||||
<input
|
|
||||||
id="title"
|
|
||||||
bind:value={fields.title}
|
|
||||||
class:error={errors.title}
|
|
||||||
/>
|
|
||||||
{#if errors.title}
|
|
||||||
<span class="error-message">{errors.title}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="slug">Course Slug</label>
|
|
||||||
<input
|
|
||||||
id="slug"
|
|
||||||
bind:value={fields.slug}
|
|
||||||
class:error={errors.slug}
|
|
||||||
/>
|
|
||||||
{#if errors.slug}
|
|
||||||
<span class="error-message">{errors.slug}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" disabled={loading}>
|
|
||||||
{loading ? 'Creating...' : 'Create Course'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Abstraction Pattern (API Classes)
|
### 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**:
|
**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.
|
- **`.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.
|
- **`.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
|
```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 {
|
// ❌ Business logic in component (slug generation, defaults)
|
||||||
async updateItem(id: string, data: Partial<Item>) {
|
const data = await api.create({
|
||||||
await this.execute<typeof classroomio.items[':id']['$put']>({
|
...fields,
|
||||||
requestFn: () => classroomio.items[':id'].$put({
|
slug: generateSlug(fields.title),
|
||||||
param: { id },
|
votes: 0,
|
||||||
json: data
|
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
|
||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$features/domain/api/entity.svelte';
|
||||||
|
|
||||||
|
let fields = $state({ title: '', body: '' });
|
||||||
|
|
||||||
|
// That's it! Just call the API method
|
||||||
|
async function handleSave() {
|
||||||
|
await api.createQuestion(fields);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSave}>
|
||||||
|
<input bind:value={fields.title} />
|
||||||
|
{#if api.errors.title}
|
||||||
|
<span class="error">{api.errors.title}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button disabled={api.isLoading}>
|
||||||
|
{api.isLoading ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
```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<typeof classroomio.community.questions.$post>({
|
||||||
|
requestFn: () => classroomio.community.questions.$post({
|
||||||
|
json: result.data
|
||||||
}),
|
}),
|
||||||
logContext: 'updating item',
|
logContext: 'creating community question',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
// Update local stores with response.data
|
if (response.data) {
|
||||||
itemsStore.update((items) => {
|
// Store the slug for navigation
|
||||||
const index = items.findIndex((i) => i.id === id);
|
this.questionSlug = response.data.slug;
|
||||||
if (index !== -1 && response.data) {
|
|
||||||
items[index] = { ...items[index], ...response.data };
|
// Show success message
|
||||||
}
|
snackbar.success('snackbar.community.success.question_submitted');
|
||||||
return items;
|
|
||||||
});
|
// Navigate to the question
|
||||||
|
goto(`${get(currentOrgPath)}/community/${response.data.slug}`);
|
||||||
|
|
||||||
|
// Mark as successful
|
||||||
|
this.success = true;
|
||||||
|
this.errors = {};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (result) => {
|
onError: (result) => {
|
||||||
// Optional: Custom error handling
|
|
||||||
if (typeof result === 'string') {
|
if (typeof result === 'string') {
|
||||||
snackbar.error(result);
|
snackbar.error('snackbar.community.error.try_again');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ('error' in result) {
|
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}<span>{api.errors.title}</span>{/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<Item>) {
|
||||||
|
// 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<typeof classroomio.items[':id']['$put']>({
|
||||||
|
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:**
|
**Key Points:**
|
||||||
- **Always use `this.execute`** - Never call the RPC client directly
|
- **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
|
- **Type the execute call** with `typeof classroomio.{route}.{method}` for type safety
|
||||||
- **Use `requestFn`** - Wrap the RPC call in a function
|
- **Use `requestFn`** - Wrap the RPC call in a function
|
||||||
- **Use `logContext`** - Provide descriptive context for error logging
|
- **Use `logContext`** - Provide descriptive context for error logging
|
||||||
- **Use `onSuccess`** - Update local stores/reactivity with response data
|
- **Use `onSuccess`** - Handle ALL success logic (stores, snackbar, navigation, state updates)
|
||||||
- **Use `onError`** - Optional custom error handling (validation errors are handled automatically by `BaseApiWithErrors`)
|
- **Use `onError`** - Handle ALL error logic (snackbar, validation errors)
|
||||||
|
- **Return early on validation failure** - Don't make API call if validation fails
|
||||||
**Examples:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Profile API - Simple update
|
|
||||||
await this.execute<typeof classroomio.account.user.$put>({
|
|
||||||
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<typeof classroomio.onboarding.step1.$post>({
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
- ✅ Automatic loading state management (`this.isLoading`)
|
- ✅ Automatic loading state management (`this.isLoading`)
|
||||||
@@ -1893,6 +2193,9 @@ await this.execute<typeof classroomio.onboarding.step1.$post>({
|
|||||||
- ✅ Type-safe responses
|
- ✅ Type-safe responses
|
||||||
- ✅ Centralized logging
|
- ✅ Centralized logging
|
||||||
- ✅ Easy to test and mock
|
- ✅ 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
|
### Server-Side API Calls
|
||||||
|
|
||||||
@@ -2040,35 +2343,65 @@ const result = await OrgPlanApiServer.createOrgPlan(planData);
|
|||||||
|
|
||||||
#### ✅ DO
|
#### ✅ 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)
|
- **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`)
|
- **Always validate first** - Use `ZodSchema.safeParse()` before making API calls
|
||||||
- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication
|
- **Handle success in `onSuccess`** - Update stores, show snackbar, navigate, reset errors
|
||||||
- **Use API key** for server-to-server communication (webhooks, external calls)
|
- **Handle errors in `onError`** - Show snackbar, map validation errors to `this.errors`
|
||||||
- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation`
|
- **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
|
- **Use entity-specific translations** when multiple entities share field names
|
||||||
|
|
||||||
```typescript
|
```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)
|
- **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**
|
- **Show user-friendly, translated error messages**
|
||||||
|
|
||||||
#### ❌ DON'T
|
#### ❌ DON'T
|
||||||
|
|
||||||
- **Call RPC client directly** in API classes (always use `execute` method)
|
**API Class Anti-Patterns:**
|
||||||
- **Call RPC client directly in server-side files** - use `.server.ts` API classes instead
|
- **DON'T call RPC client directly** in API classes (always use `execute` method)
|
||||||
- **Use `.svelte.ts` API classes in server-side files** - they're for client-side Svelte components only
|
- **DON'T return data from API methods** - Handle success in `onSuccess` callback
|
||||||
- **Include API key logic in `.svelte.ts` files** - API keys must only be in `.server.ts` files
|
- **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
|
- Skip error handling assuming API calls always succeed
|
||||||
- Display raw API error messages without formatting
|
- 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)
|
- Hardcode error messages in schemas (use translation system)
|
||||||
- Use the same generic message for all entities (use entity-specific when needed)
|
- Use the same generic message for all entities (use entity-specific when needed)
|
||||||
- Make multiple simultaneous calls that should be atomic
|
- Make multiple simultaneous calls that should be atomic
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { dashAnalyticsRouter } from '@api/routes/dash';
|
|||||||
import { mediaRouter } from '@api/routes/media';
|
import { mediaRouter } from '@api/routes/media';
|
||||||
import { onboardingRouter } from '@api/routes/onboarding';
|
import { onboardingRouter } from '@api/routes/onboarding';
|
||||||
import { organizationRouter } from '@api/routes/organization';
|
import { organizationRouter } from '@api/routes/organization';
|
||||||
|
import { communityRouter } from '@api/routes/community';
|
||||||
|
|
||||||
// Create Hono app with chaining for RPC support
|
// Create Hono app with chaining for RPC support
|
||||||
export const app = new Hono()
|
export const app = new Hono()
|
||||||
@@ -77,6 +78,7 @@ export const app = new Hono()
|
|||||||
.route('/media', mediaRouter)
|
.route('/media', mediaRouter)
|
||||||
.route('/organization', organizationRouter)
|
.route('/organization', organizationRouter)
|
||||||
.route('/dash', dashAnalyticsRouter)
|
.route('/dash', dashAnalyticsRouter)
|
||||||
|
.route('/community', communityRouter)
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './community';
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,17 +9,21 @@ import {
|
|||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { cloneCourse } from '@api/services/course/clone';
|
import { cloneCourse } from '@api/services/course/clone';
|
||||||
|
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||||
import { generateCertificate } from '@api/utils/certificate';
|
import { generateCertificate } from '@api/utils/certificate';
|
||||||
import { generateCoursePdf } from '@api/utils/course';
|
import { generateCoursePdf } from '@api/utils/course';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { katexRouter } from '@api/routes/course/katex';
|
import { katexRouter } from '@api/routes/course/katex';
|
||||||
import { lessonRouter } from '@api/routes/course/lesson';
|
import { lessonRouter } from '@api/routes/course/lesson';
|
||||||
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
import { presignRouter } from '@api/routes/course/presign';
|
import { presignRouter } from '@api/routes/course/presign';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const courseRouter = new Hono()
|
export const courseRouter = new Hono()
|
||||||
.post(
|
.post(
|
||||||
'/:courseId/download/certificate',
|
'/:courseId/download/certificate',
|
||||||
|
authMiddleware,
|
||||||
|
courseMemberMiddleware,
|
||||||
zValidator('param', ZCourseDownloadParam),
|
zValidator('param', ZCourseDownloadParam),
|
||||||
zValidator('json', ZCertificateDownload),
|
zValidator('json', ZCertificateDownload),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -42,6 +46,8 @@ export const courseRouter = new Hono()
|
|||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
'/:courseId/download/content',
|
'/:courseId/download/content',
|
||||||
|
authMiddleware,
|
||||||
|
courseMemberMiddleware,
|
||||||
zValidator('param', ZCourseDownloadParam),
|
zValidator('param', ZCourseDownloadParam),
|
||||||
zValidator('json', ZCourseDownloadContent),
|
zValidator('json', ZCourseDownloadContent),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -64,6 +70,7 @@ export const courseRouter = new Hono()
|
|||||||
.post(
|
.post(
|
||||||
'/:courseId/clone',
|
'/:courseId/clone',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
zValidator('param', ZCourseCloneParam),
|
zValidator('param', ZCourseCloneParam),
|
||||||
zValidator('json', ZCourseClone),
|
zValidator('json', ZCourseClone),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -90,5 +97,5 @@ export const courseRouter = new Hono()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.route('/katex', katexRouter)
|
.route('/katex', katexRouter)
|
||||||
.route('/lesson', lessonRouter)
|
.route('/:courseId/lesson', lessonRouter)
|
||||||
.route('/presign', presignRouter);
|
.route('/presign', presignRouter);
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
|
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
|
||||||
|
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||||
import { generateLessonPdf } from '@api/utils/lesson';
|
import { generateLessonPdf } from '@api/utils/lesson';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const lessonRouter = new Hono().post('/download/pdf', zValidator('json', ZLessonDownloadContent), async (c) => {
|
export const lessonRouter = new Hono().post(
|
||||||
const validatedData = c.req.valid('json');
|
'/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');
|
c.header('Content-Type', 'application/pdf');
|
||||||
return c.body(
|
return c.body(
|
||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
controller.enqueue(pdfBuffer);
|
controller.enqueue(pdfBuffer);
|
||||||
controller.close();
|
controller.close();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { ZDashStats } from '@cio/utils/validation/dash';
|
|||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { getOrganisationAnalytics } from '@api/services/dash';
|
import { getOrganisationAnalytics } from '@api/services/dash';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const dashAnalyticsRouter = new Hono().get(
|
export const dashAnalyticsRouter = new Hono().get(
|
||||||
'/stats',
|
'/stats',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
zValidator('query', ZDashStats),
|
zValidator('query', ZDashStats),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { ZDeliverEmail } from '@cio/utils/validation/mail';
|
import { ZDeliverEmail } from '@cio/utils/validation/mail';
|
||||||
|
import { apiKeyMiddleware } from '@api/middlewares/api-key';
|
||||||
import { deliverEmail } from '@cio/email';
|
import { deliverEmail } from '@cio/email';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
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');
|
const validatedData = c.req.valid('json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { authMiddleware } from '@api/middlewares/auth';
|
|||||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||||
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const organizationRouter = new Hono()
|
export const organizationRouter = new Hono()
|
||||||
@@ -66,7 +67,7 @@ export const organizationRouter = new Hono()
|
|||||||
* Gets organization team members (non-students)
|
* Gets organization team members (non-students)
|
||||||
* Requires authentication
|
* Requires authentication
|
||||||
*/
|
*/
|
||||||
.get('/:orgId/team', authMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => {
|
.get('/:orgId/team', authMiddleware, orgMemberMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => {
|
||||||
try {
|
try {
|
||||||
const { orgId } = c.req.valid('param');
|
const { orgId } = c.req.valid('param');
|
||||||
const team = await getOrgTeam(orgId);
|
const team = await getOrgTeam(orgId);
|
||||||
@@ -144,22 +145,28 @@ export const organizationRouter = new Hono()
|
|||||||
* Gets organization audience (students)
|
* Gets organization audience (students)
|
||||||
* Requires authentication
|
* Requires authentication
|
||||||
*/
|
*/
|
||||||
.get('/:orgId/audience', authMiddleware, zValidator('param', ZGetOrganizationAudience), async (c) => {
|
.get(
|
||||||
try {
|
'/:orgId/audience',
|
||||||
const { orgId } = c.req.valid('param');
|
authMiddleware,
|
||||||
const audience = await getOrgAudience(orgId);
|
orgMemberMiddleware,
|
||||||
|
zValidator('param', ZGetOrganizationAudience),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { orgId } = c.req.valid('param');
|
||||||
|
const audience = await getOrgAudience(orgId);
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
data: audience
|
data: audience
|
||||||
},
|
},
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to fetch organization audience');
|
return handleError(c, error, 'Failed to fetch organization audience');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
/**
|
/**
|
||||||
* GET /organization/courses
|
* GET /organization/courses
|
||||||
* Gets courses by organization siteName
|
* Gets courses by organization siteName
|
||||||
@@ -305,6 +312,7 @@ export const organizationRouter = new Hono()
|
|||||||
.put(
|
.put(
|
||||||
'/:orgId',
|
'/:orgId',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
orgAdminMiddleware,
|
||||||
zValidator('param', ZUpdateOrganizationParam),
|
zValidator('param', ZUpdateOrganizationParam),
|
||||||
zValidator('json', ZUpdateOrganization),
|
zValidator('json', ZUpdateOrganization),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
|||||||
@@ -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<Partial<TCommunityQuestion>[]> {
|
||||||
|
let discussions: Partial<TCommunityQuestion>[] = [];
|
||||||
|
|
||||||
|
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<TCommunityQuestion>) {
|
||||||
|
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<TCommunityQuestion>) {
|
||||||
|
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<TCommunityQuestion>) {
|
||||||
|
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<TCommunityAnswer>) {
|
||||||
|
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<TCommunityQuestion>) {
|
||||||
|
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<TCommunityAnswer>) {
|
||||||
|
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<TCommunityAnswer>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
updateOrganizationPlan
|
updateOrganizationPlan
|
||||||
} from '@cio/db/queries/organization';
|
} from '@cio/db/queries/organization';
|
||||||
import {
|
import {
|
||||||
|
getCoursesById,
|
||||||
getCoursesBySiteName,
|
getCoursesBySiteName,
|
||||||
getCoursesBySiteNameForSetup,
|
getCoursesBySiteNameForSetup,
|
||||||
getExercisesBySiteName,
|
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
|
* Gets setup data for an organization
|
||||||
* @param siteName - The organization site name
|
* @param siteName - The organization site name
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ export const ErrorCodes = {
|
|||||||
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
|
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
|
||||||
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
|
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
|
// Organization errors
|
||||||
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
||||||
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
||||||
|
|||||||
@@ -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<Course[]>([]);
|
||||||
|
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<typeof classroomio.community.$get>({
|
||||||
|
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<CreateCommunityQuestionResponse>({
|
||||||
|
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<CreateCommentResponse>({
|
||||||
|
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<UpvotePostResponse>({
|
||||||
|
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<UpdateQuestionResponse>({
|
||||||
|
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();
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
|
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||||
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
|
import { Vote } from '$features/ui';
|
||||||
|
import { CircleCheckIcon } from '$features/ui/icons';
|
||||||
|
|
||||||
|
interface CommentAuthor {
|
||||||
|
id: number | string | null;
|
||||||
|
fullname?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentItem {
|
||||||
|
id: string;
|
||||||
|
votes?: number | null;
|
||||||
|
body: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
author?: CommentAuthor | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
comment: CommentItem;
|
||||||
|
isVoted?: boolean | null;
|
||||||
|
isValidAnswer?: boolean;
|
||||||
|
isAuthorOrAdmin: boolean;
|
||||||
|
upVote: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { comment, isVoted, isValidAnswer = false, isAuthorOrAdmin, upVote, onDelete }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-start px-1">
|
||||||
|
<Vote value={comment.votes ?? 0} {upVote} disabled={isVoted ?? false} />
|
||||||
|
<div class="border-gray w-full rounded-lg border">
|
||||||
|
<header class="flex items-center justify-between p-2 leading-none">
|
||||||
|
<div class="flex items-center text-black">
|
||||||
|
<Avatar.Root class="size-7">
|
||||||
|
<Avatar.Image
|
||||||
|
src={comment.author?.avatarUrl ? comment.author.avatarUrl : '/logo-192.png'}
|
||||||
|
alt={comment.author?.fullname ? comment.author.fullname : 'User'}
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback>{shortenName(comment.author?.fullname) || 'U'}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
|
||||||
|
<p class="ml-2 text-sm dark:text-white">{comment.author?.fullname}</p>
|
||||||
|
|
||||||
|
<p class="ui:text-muted-foreground ml-2 text-sm">
|
||||||
|
{comment.createdAt ? calDateDiff(comment.createdAt) : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isValidAnswer}
|
||||||
|
<CircleCheckIcon size={16} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isAuthorOrAdmin}
|
||||||
|
<IconButton onclick={onDelete}>
|
||||||
|
<TrashIcon size={16} />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<article class="prose prose-sm sm:prose p-2">
|
||||||
|
{@html comment.body}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as CommunityListLoader } from './community-list-loader.svelte';
|
export { default as CommunityListLoader } from './community-list-loader.svelte';
|
||||||
export { default as CommunityDeleteModal } from './delete-modal.svelte';
|
export { default as CommunityDeleteModal } from './delete-modal.svelte';
|
||||||
export { default as AskCommunityButton } from './ask-community-button.svelte';
|
export { default as AskCommunityButton } from './ask-community-button.svelte';
|
||||||
|
export { default as CommunityCommentItem } from './community-comment-item.svelte';
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Select from '@cio/ui/base/select';
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
|
import { TextEditor } from '$features/ui';
|
||||||
|
|
||||||
|
import { profile } from '$lib/utils/store/user';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
|
import { communityApi } from '$features/community/api/community.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isLMS?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isLMS = false }: Props = $props();
|
||||||
|
|
||||||
|
let fields = $state({
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
courseId: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($profile.id && $currentOrg.id) {
|
||||||
|
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-3 flex justify-between gap-x-5">
|
||||||
|
<InputField
|
||||||
|
bind:value={fields.title}
|
||||||
|
placeholder={$t('community.ask.title')}
|
||||||
|
errorMessage={communityApi.errors.title}
|
||||||
|
className="w-[75%]"
|
||||||
|
/>
|
||||||
|
<div class="w-[25%]">
|
||||||
|
<Select.Root type="single" bind:value={fields.courseId}>
|
||||||
|
<Select.Trigger class="h-full w-full">
|
||||||
|
<p>
|
||||||
|
{fields.courseId
|
||||||
|
? communityApi.courses.find((course) => course.id === fields.courseId)?.title
|
||||||
|
: $t('community.ask.select_course')}
|
||||||
|
</p>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each communityApi.courses as course}
|
||||||
|
{#if course.id}
|
||||||
|
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if communityApi.errors.courseId}
|
||||||
|
<p class="mt-1 text-sm text-red-500">
|
||||||
|
{communityApi.errors.courseId}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-2">
|
||||||
|
<TextEditor placeholder="Give an answer" content={fields.body} onChange={(content) => (fields.body = content)} />
|
||||||
|
|
||||||
|
{#if communityApi.errors.body}
|
||||||
|
<p class="mt-2 text-sm text-red-500">{communityApi.errors.body}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onclick={() => communityApi.createQuestion(fields, isLMS)}
|
||||||
|
disabled={communityApi.isLoading}
|
||||||
|
>
|
||||||
|
{$t('community.ask.publish')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import pluralize from 'pluralize';
|
||||||
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
import * as Select from '@cio/ui/base/select';
|
||||||
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
|
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||||
|
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||||
|
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||||
|
import { communityApi } from '$features/community/api/community.svelte';
|
||||||
|
import type { TiptapEditor } from '@cio/ui/custom/editor';
|
||||||
|
|
||||||
|
import { profile } from '$lib/utils/store/user';
|
||||||
|
import { Vote } from '$features/ui';
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
|
import { TextEditor } from '$features/ui';
|
||||||
|
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
slug: string;
|
||||||
|
isLMS?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { slug, isLMS = false }: Props = $props();
|
||||||
|
|
||||||
|
let commentEditor: TiptapEditor | undefined = $state();
|
||||||
|
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
|
||||||
|
let voted: {
|
||||||
|
question: boolean;
|
||||||
|
comment: {
|
||||||
|
[key: string]: boolean;
|
||||||
|
};
|
||||||
|
} = $state({ question: false, comment: {} });
|
||||||
|
let deleteQuestionState = $state({
|
||||||
|
shouldDelete: false,
|
||||||
|
questionId: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($profile.id && $currentOrg.id) {
|
||||||
|
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||||
|
|
||||||
|
communityApi.fetchCommunityQuestion({ slug, isLMS });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{communityApi.question?.title || $t('community.ask.question')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<CommunityDeleteModal
|
||||||
|
bind:open={communityApi.deleteCommentState.shouldDelete}
|
||||||
|
isDeleting={communityApi.isLoading}
|
||||||
|
onCancel={() => {
|
||||||
|
communityApi.deleteCommentState.shouldDelete = false;
|
||||||
|
communityApi.deleteCommentState.commentId = '';
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
communityApi.deleteComment(communityApi.deleteCommentState.commentId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CommunityDeleteModal
|
||||||
|
bind:open={deleteQuestionState.shouldDelete}
|
||||||
|
isDeleting={communityApi.isLoading}
|
||||||
|
isQuestion={true}
|
||||||
|
onCancel={() => {
|
||||||
|
deleteQuestionState.shouldDelete = false;
|
||||||
|
deleteQuestionState.questionId = '';
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (communityApi.question) {
|
||||||
|
communityApi.deleteQuestion(String(communityApi.question.id), isLMS);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if !communityApi.question}
|
||||||
|
<div class="mb-3 space-y-2 px-5 py-10">
|
||||||
|
<Skeleton class="h-4 w-[25%]" />
|
||||||
|
<Skeleton class="h-4 w-full" />
|
||||||
|
<Skeleton class="h-80 w-full" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
|
||||||
|
<Page.Header>
|
||||||
|
<Page.HeaderContent>
|
||||||
|
{#if communityApi.isEditMode}
|
||||||
|
<div class="flex w-full items-center gap-2">
|
||||||
|
<InputField
|
||||||
|
bind:value={communityApi.editContent.title}
|
||||||
|
className="flex-1"
|
||||||
|
errorMessage={communityApi.errors.title}
|
||||||
|
isDisabled={communityApi.isEditing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select.Root type="single" bind:value={communityApi.editContent.courseId} disabled={communityApi.isEditing}>
|
||||||
|
<Select.Trigger class="w-30! h-full truncate" disabled={communityApi.isEditing}>
|
||||||
|
<span class="truncate">
|
||||||
|
{communityApi.editContent.courseId
|
||||||
|
? communityApi.courses.find((course) => course.id === communityApi.editContent.courseId)?.title
|
||||||
|
: $t('community.ask.select_course')}
|
||||||
|
</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each communityApi.courses as course}
|
||||||
|
{#if course.id}
|
||||||
|
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Vote
|
||||||
|
value={communityApi.question.votes}
|
||||||
|
upVote={() => {
|
||||||
|
if (voted.question || !communityApi.question) return;
|
||||||
|
voted.question = true;
|
||||||
|
communityApi.upvotePost({
|
||||||
|
id: Number(communityApi.question.id),
|
||||||
|
isQuestion: true
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={voted.question}
|
||||||
|
/>
|
||||||
|
<Page.Title>{communityApi.question.title}</Page.Title>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Page.HeaderContent>
|
||||||
|
{#if communityApi.question.authorId === $profile.id}
|
||||||
|
<Page.Action>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
loading={communityApi.isEditing}
|
||||||
|
onclick={() => {
|
||||||
|
if (!communityApi.question) return;
|
||||||
|
|
||||||
|
if (communityApi.isEditMode) {
|
||||||
|
communityApi.updateQuestion(Number(communityApi.question.id), communityApi.editContent);
|
||||||
|
} else {
|
||||||
|
communityApi.enterEditMode(communityApi.question);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{communityApi.isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if communityApi.isEditMode}
|
||||||
|
<Button variant="ghost" disabled={communityApi.isEditing} onclick={() => communityApi.exitEditMode()}>
|
||||||
|
{$t('community.ask.cancel')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Page.Action>
|
||||||
|
{/if}
|
||||||
|
</Page.Header>
|
||||||
|
|
||||||
|
<Page.Body>
|
||||||
|
{#snippet child()}
|
||||||
|
<div class="my-1 rounded-md border px-1">
|
||||||
|
<div class="flex items-center justify-between p-2 leading-none">
|
||||||
|
<div class="flex items-center text-black no-underline hover:underline">
|
||||||
|
<Avatar.Root class="h-7 w-7">
|
||||||
|
<Avatar.Image
|
||||||
|
src={communityApi.question?.authorAvatarUrl
|
||||||
|
? communityApi.question?.authorAvatarUrl
|
||||||
|
: '/logo-192.png'}
|
||||||
|
alt={communityApi.question?.authorFullname ? communityApi.question?.authorFullname : 'User'}
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback>{shortenName(communityApi.question?.authorFullname) || 'U'}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<p class="ml-2 text-sm dark:text-white">{communityApi.question?.authorFullname}</p>
|
||||||
|
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||||
|
{communityApi.question?.createdAt ? calDateDiff(communityApi.question.createdAt) : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if communityApi.question?.authorId === $profile.id || $isOrgAdmin}
|
||||||
|
<IconButton
|
||||||
|
onclick={() => {
|
||||||
|
if (!communityApi.question) return;
|
||||||
|
|
||||||
|
deleteQuestionState.shouldDelete = true;
|
||||||
|
deleteQuestionState.questionId = String(communityApi.question.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon size={16} />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if communityApi.isEditMode}
|
||||||
|
<div class="my-2">
|
||||||
|
<TextEditor
|
||||||
|
placeholder="Give an answer"
|
||||||
|
content={communityApi.editContent.body}
|
||||||
|
onChange={(content) => (communityApi.editContent.body = content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<section class="prose prose-sm sm:prose p-2">
|
||||||
|
{@html communityApi.question?.body}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-8">
|
||||||
|
{pluralize($t('community.answers'), communityApi.question?.comments.length ?? 0, true)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each communityApi.question?.comments as commentItem}
|
||||||
|
<CommunityCommentItem
|
||||||
|
comment={commentItem}
|
||||||
|
isVoted={!!voted.comment[commentItem.id]}
|
||||||
|
{isValidAnswer}
|
||||||
|
isAuthorOrAdmin={commentItem.author?.id === $profile.id || $isOrgAdmin === true}
|
||||||
|
upVote={() => {
|
||||||
|
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}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<TextEditor
|
||||||
|
placeholder="Give an answer"
|
||||||
|
class="h-48!"
|
||||||
|
bind:content={communityApi.comment}
|
||||||
|
onReady={(editor) => (commentEditor = editor)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
console.log('comment', communityApi.comment);
|
||||||
|
console.log('question', communityApi.question);
|
||||||
|
if (communityApi.question) {
|
||||||
|
communityApi.createComment(Number(communityApi.question.id), isLMS, () => {
|
||||||
|
commentEditor?.commands?.clearContent?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={communityApi.isCommenting}
|
||||||
|
>
|
||||||
|
{$t('community.ask.comment')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Page.Body>
|
||||||
|
</Page.Root>
|
||||||
|
{/if}
|
||||||
@@ -1,26 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import * as Select from '@cio/ui/base/select';
|
import * as Select from '@cio/ui/base/select';
|
||||||
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
|
import * as Item from '@cio/ui/base/item';
|
||||||
import { Search } from '@cio/ui/custom/search';
|
import { Search } from '@cio/ui/custom/search';
|
||||||
|
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||||
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
||||||
|
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courses } from '$features/course/utils/store';
|
import { communityApi } from '../api/community.svelte';
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
import { supabase } from '$lib/utils/functions/supabase';
|
|
||||||
import { fetchCourses } from '$lib/utils/services/courses';
|
|
||||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
|
||||||
|
|
||||||
import { CommunityListLoader } from '../components';
|
|
||||||
import { Vote } from '$features/ui';
|
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
|
||||||
import * as Item from '@cio/ui/base/item';
|
|
||||||
|
|
||||||
import { AskCommunityButton } from '../components';
|
import { AskCommunityButton } from '../components';
|
||||||
|
import { CommunityListLoader } from '../components';
|
||||||
|
import { Vote } from '$features/ui';
|
||||||
|
import type { CommunityQuestionData } from '../utils/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLMS?: boolean;
|
isLMS?: boolean;
|
||||||
@@ -28,70 +25,17 @@
|
|||||||
|
|
||||||
let { isLMS = false }: Props = $props();
|
let { isLMS = false }: Props = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
|
||||||
let discussions = $state([]);
|
|
||||||
let searchValue = $state('');
|
let searchValue = $state('');
|
||||||
let allCourses: any[] = $state([]);
|
|
||||||
let selectedId = $state('');
|
let selectedId = $state('');
|
||||||
|
|
||||||
function fetchCommunityQuestions(orgId?: string, profileId?: string) {
|
function fetchCommunityQuestions(orgId?: string, profileId?: string) {
|
||||||
if (!orgId || !profileId) return;
|
if (!orgId || !profileId) return;
|
||||||
|
|
||||||
untrack(async () => {
|
untrack(async () => {
|
||||||
isLoading = true;
|
// Fetch courses - API class handles this
|
||||||
|
await communityApi.fetchCoursesForOrg(profileId, orgId);
|
||||||
if ($courses.length) {
|
// Call API - API class handles loading, errors, navigation
|
||||||
allCourses = [...$courses];
|
await communityApi.fetchCommunityQuestions({ orgId, isLMS });
|
||||||
} else {
|
|
||||||
const courseResult = (await fetchCourses(profileId, orgId)) || { allCourses: [] };
|
|
||||||
allCourses = courseResult.allCourses;
|
|
||||||
}
|
|
||||||
|
|
||||||
const courseIds = allCourses.map((course) => course.id);
|
|
||||||
const courseIdsFilter = `(${courseIds.join(',')})`;
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
organization_id,
|
|
||||||
course_id,
|
|
||||||
title,
|
|
||||||
votes,
|
|
||||||
created_at,
|
|
||||||
slug,
|
|
||||||
comments:community_answer(count),
|
|
||||||
author:profile(
|
|
||||||
fullname
|
|
||||||
),
|
|
||||||
course!inner (
|
|
||||||
title
|
|
||||||
)
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.filter('course_id', 'in', courseIdsFilter)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
console.log('data', data);
|
|
||||||
console.log('error', error);
|
|
||||||
|
|
||||||
isLoading = false;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error loading community', error);
|
|
||||||
return goto(isLMS ? '/lms' : $currentOrgPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
discussions =
|
|
||||||
data?.map((discussion) => ({
|
|
||||||
title: discussion.title,
|
|
||||||
courseId: discussion.course_id,
|
|
||||||
courseTitle: discussion.course?.title,
|
|
||||||
slug: discussion.slug,
|
|
||||||
author: discussion?.author?.fullname,
|
|
||||||
comments: discussion.comments?.[0]?.count || 0,
|
|
||||||
votes: discussion.votes,
|
|
||||||
createdAt: calDateDiff(discussion.created_at)
|
|
||||||
})) || [];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,11 +43,11 @@
|
|||||||
fetchCommunityQuestions($currentOrg.id, $profile.id);
|
fetchCommunityQuestions($currentOrg.id, $profile.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
let filteredDiscussions = $derived(
|
let filteredQuestions = $derived(
|
||||||
discussions.filter(
|
communityApi.questions.filter(
|
||||||
(discussion) =>
|
(question: CommunityQuestionData[number]) =>
|
||||||
discussion.title.toLowerCase().includes(searchValue.toLowerCase()) &&
|
question.title.toLowerCase().includes(searchValue.toLowerCase()) &&
|
||||||
(!selectedId || discussion.courseId === selectedId)
|
(!selectedId || question.courseId === selectedId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -114,46 +58,50 @@
|
|||||||
<Select.Root type="single" bind:value={selectedId}>
|
<Select.Root type="single" bind:value={selectedId}>
|
||||||
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
|
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
|
||||||
<p class="truncate">
|
<p class="truncate">
|
||||||
{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')}
|
||||||
</p>
|
</p>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
<Select.Item value="">{$t('community.all')}</Select.Item>
|
<Select.Item value="">{$t('community.all')}</Select.Item>
|
||||||
{#each allCourses as course}
|
{#each communityApi.courses as course}
|
||||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
{#if course.id}
|
||||||
|
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</Page.BodyHeader>
|
</Page.BodyHeader>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if communityApi.isLoading}
|
||||||
<CommunityListLoader />
|
<CommunityListLoader />
|
||||||
<CommunityListLoader />
|
<CommunityListLoader />
|
||||||
<CommunityListLoader />
|
<CommunityListLoader />
|
||||||
<CommunityListLoader />
|
<CommunityListLoader />
|
||||||
{:else}
|
{:else}
|
||||||
{#each filteredDiscussions as discussion}
|
{#each filteredQuestions as question}
|
||||||
<Item.Root variant="outline">
|
<Item.Root variant="outline">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="{isLMS ? '/lms' : $currentOrgPath}/community/{discussion.slug}" {...props}>
|
<a href="{isLMS ? '/lms' : $currentOrgPath}/community/{question.slug}" {...props}>
|
||||||
<Vote value={discussion.votes} />
|
<Vote value={question.votes} />
|
||||||
<Item.Content class="gap-y-0.5">
|
<Item.Content class="gap-y-0.5">
|
||||||
<Item.Title class="mt-0">
|
<Item.Title class="mt-0">
|
||||||
{discussion.title}
|
{question.title}
|
||||||
</Item.Title>
|
</Item.Title>
|
||||||
<Item.Description>
|
<Item.Description>
|
||||||
{discussion.author} asked {discussion.createdAt}
|
{question.authorFullname} asked {calDateDiff(question.createdAt)}
|
||||||
</Item.Description>
|
</Item.Description>
|
||||||
<a class="m-0" href="/courses/{discussion.courseId}" onclick={(e) => e.stopPropagation()}>
|
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
|
||||||
<span class="text-muted-foreground p-0 text-xs">
|
<span class="text-muted-foreground p-0 text-xs">
|
||||||
#{discussion.courseTitle}
|
#{question.courseTitle}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</Item.Content>
|
</Item.Content>
|
||||||
<Item.Actions>
|
<Item.Actions>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<MessageCirclePlusIcon size={16} />
|
<MessageCirclePlusIcon size={16} />
|
||||||
<span class="ml-1">{discussion.comments}</span>
|
<span class="ml-1">{question.comments}</span>
|
||||||
</div>
|
</div>
|
||||||
</Item.Actions>
|
</Item.Actions>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export { default as CommunityPage } from './community.svelte';
|
export { default as CommunityPage } from './community.svelte';
|
||||||
|
export { default as AskCommunityPage } from './ask-community.svelte';
|
||||||
|
export { default as CommunityQuestionPage } from './community-question.svelte';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||||
|
|
||||||
|
export type GetCommunityQuestionsResponse = typeof classroomio.community.$get;
|
||||||
|
export type CommunityQuestionsResponse = InferResponseType<GetCommunityQuestionsResponse> | null;
|
||||||
|
export type CommunityQuestionsSuccess = Extract<InferResponseType<GetCommunityQuestionsResponse>, { success: true }>;
|
||||||
|
export type CommunityQuestionData = CommunityQuestionsSuccess['data'];
|
||||||
|
|
||||||
|
export type GetCommunityQuestionResponse = (typeof classroomio.community)[':slug']['$get'];
|
||||||
|
export type CommunityQuestionResponse = InferResponseType<GetCommunityQuestionResponse> | null;
|
||||||
|
export type CommunityQuestionSuccess = Extract<InferResponseType<GetCommunityQuestionResponse>, { success: true }>;
|
||||||
|
|
||||||
|
export type CreateCommentResponse = typeof classroomio.community.$post;
|
||||||
|
export type CreateCommentSuccess = Extract<InferResponseType<CreateCommentResponse>, { success: true }>;
|
||||||
|
export type CreateCommentData = CreateCommentSuccess['data'];
|
||||||
|
|
||||||
|
export type UpvotePostResponse = (typeof classroomio.community)[':id']['upvote']['$post'];
|
||||||
|
export type UpvotePostSuccess = Extract<InferResponseType<UpvotePostResponse>, { success: true }>;
|
||||||
|
export type UpvotePostData = UpvotePostSuccess['data'];
|
||||||
|
|
||||||
|
export type CreateCommunityQuestionResponse = typeof classroomio.community.$post;
|
||||||
|
export type CreateCommunityQuestionSuccess = Extract<
|
||||||
|
InferResponseType<CreateCommunityQuestionResponse>,
|
||||||
|
{ success: true }
|
||||||
|
>;
|
||||||
|
export type UpdateQuestionResponse = (typeof classroomio.community)[':id']['$put'];
|
||||||
|
export type UpdateQuestionSuccess = Extract<InferResponseType<UpdateQuestionResponse>, { success: true }>;
|
||||||
|
export type UpdateQuestionData = UpdateQuestionSuccess['data'];
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
content,
|
content = $bindable(''),
|
||||||
showToolBar = true,
|
showToolBar = true,
|
||||||
editable = true,
|
editable = true,
|
||||||
enablePersistence = false,
|
enablePersistence = false,
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
content={content || ''}
|
bind:content
|
||||||
{showToolBar}
|
{showToolBar}
|
||||||
{editable}
|
{editable}
|
||||||
{enablePersistence}
|
{enablePersistence}
|
||||||
@@ -56,4 +56,3 @@
|
|||||||
onEditorReady={onReady}
|
onEditorReady={onReady}
|
||||||
{onEditorDestroy}
|
{onEditorDestroy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1538,6 +1538,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"success": {
|
"success": {
|
||||||
|
"question_updated": "Question Updated!",
|
||||||
"comment_submitted": "Comment Submitted!",
|
"comment_submitted": "Comment Submitted!",
|
||||||
"success_delete": "Deleted successfully",
|
"success_delete": "Deleted successfully",
|
||||||
"question_submitted": "Question Submitted!"
|
"question_submitted": "Question Submitted!"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>ClassroomIO - The Coure Platform That’s Actually Easy To Use</title>
|
<title>ClassroomIO - The Course Platform That’s Actually Easy To Use</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !isLoadingApp && appInitApi.error}
|
{#if !isLoadingApp && appInitApi.error}
|
||||||
|
|||||||
@@ -1,553 +1,11 @@
|
|||||||
<script lang="ts">
|
<script>
|
||||||
import { untrack } from 'svelte';
|
import { CommunityQuestionPage } from '$features/community/pages';
|
||||||
import pluralize from 'pluralize';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import * as Select from '@cio/ui/base/select';
|
|
||||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
|
||||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
|
||||||
|
|
||||||
import type { Course } from '$lib/utils/types';
|
|
||||||
import { profile } from '$lib/utils/store/user';
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
|
||||||
import { courses } from '$features/course/utils/store';
|
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
|
||||||
import { supabase } from '$lib/utils/functions/supabase';
|
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
|
||||||
import { fetchCourses } from '$lib/utils/services/courses';
|
|
||||||
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
|
|
||||||
|
|
||||||
import { Vote } from '$features/ui';
|
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import { TextEditor } from '$features/ui';
|
|
||||||
import { CircleCheckIcon } from '$features/ui/icons';
|
|
||||||
import { CommunityDeleteModal } from '$features/community/components';
|
|
||||||
import { currentCommunityQuestion } from '$features/community/utils/store';
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
|
||||||
|
|
||||||
interface Comment {
|
|
||||||
id: string;
|
|
||||||
authorId: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
votes: number;
|
|
||||||
comment: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
interface Question {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
votes: number;
|
|
||||||
author: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
comments: Comment[];
|
|
||||||
totalComments: number;
|
|
||||||
courseId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const { slug } = data;
|
|
||||||
|
|
||||||
let question: Question | undefined = $state();
|
|
||||||
let comment = $state('');
|
|
||||||
let errors: {
|
|
||||||
title?: string;
|
|
||||||
courseId?: string;
|
|
||||||
} = $state({});
|
|
||||||
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
|
|
||||||
let voted: {
|
|
||||||
question: boolean;
|
|
||||||
comment: {
|
|
||||||
[key: string]: boolean;
|
|
||||||
};
|
|
||||||
} = $state({ question: false, comment: {} });
|
|
||||||
let isEditMode = $state(false);
|
|
||||||
let deleteComment = $state({
|
|
||||||
shouldDelete: false,
|
|
||||||
commentId: '',
|
|
||||||
isDeleting: false
|
|
||||||
});
|
|
||||||
let deleteQuestion = $state({
|
|
||||||
shouldDelete: false,
|
|
||||||
questionId: '',
|
|
||||||
isDeleting: false
|
|
||||||
});
|
|
||||||
let editContent = $state({
|
|
||||||
title: '',
|
|
||||||
body: '',
|
|
||||||
courseId: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
let editorInstance = $state(false);
|
|
||||||
let fetchedCourses: Course[] = $state([]);
|
|
||||||
|
|
||||||
function mapResToQuestion(data): Question {
|
|
||||||
return {
|
|
||||||
id: data.id,
|
|
||||||
title: data.title,
|
|
||||||
votes: data.votes,
|
|
||||||
author: {
|
|
||||||
id: data?.author?.id || '',
|
|
||||||
name: data?.author?.fullname || '',
|
|
||||||
avatar: data?.author?.avatar_url || ''
|
|
||||||
},
|
|
||||||
body: data.body,
|
|
||||||
createdAt: calDateDiff(data.created_at),
|
|
||||||
comments: data.comments.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
authorId: c.author?.id || '',
|
|
||||||
name: c.author?.fullname || '',
|
|
||||||
avatar: c.author?.avatar_url || '',
|
|
||||||
votes: c.votes,
|
|
||||||
comment: c.body,
|
|
||||||
createdAt: calDateDiff(c.created_at)
|
|
||||||
})),
|
|
||||||
totalComments: 0,
|
|
||||||
courseId: data.course_id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCourses(userId: string | null, orgId: string) {
|
|
||||||
if ($courses.length) {
|
|
||||||
fetchedCourses = [...$courses];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coursesResults = await fetchCourses(userId, orgId);
|
|
||||||
fetchedCourses = coursesResults?.allCourses || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCommunityQuestion(slug: string) {
|
|
||||||
if (!slug) return;
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
votes,
|
|
||||||
created_at,
|
|
||||||
course_id,
|
|
||||||
slug,
|
|
||||||
comments:community_answer(
|
|
||||||
id,
|
|
||||||
body,
|
|
||||||
votes,
|
|
||||||
created_at,
|
|
||||||
author:profile(id, fullname, avatar_url)
|
|
||||||
),
|
|
||||||
author:profile(id, fullname, avatar_url),
|
|
||||||
course!inner (
|
|
||||||
title
|
|
||||||
)
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.eq('slug', slug)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('[LMS] Error loading community', error);
|
|
||||||
return goto(`/lms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
untrack(() => {
|
|
||||||
question = mapResToQuestion(data);
|
|
||||||
question.totalComments = question.comments.length;
|
|
||||||
// Update store for breadcrumb
|
|
||||||
currentCommunityQuestion.set({ title: question.title });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitComment() {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
errors = commentInCommunityValidation({ comment });
|
|
||||||
console.log('submitComment errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('community_answer')
|
|
||||||
.insert({
|
|
||||||
id: undefined,
|
|
||||||
body: comment,
|
|
||||||
question_id: question.id,
|
|
||||||
author_profile_id: $profile.id,
|
|
||||||
votes: 0
|
|
||||||
})
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: commenting', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
console.log('Success: commenting', data);
|
|
||||||
|
|
||||||
snackbar.success('snackbar.community.success.comment_submitted');
|
|
||||||
|
|
||||||
// Add to comment
|
|
||||||
const _c = data?.[0];
|
|
||||||
question.comments = [
|
|
||||||
...question.comments,
|
|
||||||
{
|
|
||||||
id: _c.id,
|
|
||||||
authorId: $profile.id || '',
|
|
||||||
name: $profile?.fullname || '',
|
|
||||||
avatar: $profile?.avatarUrl || '',
|
|
||||||
votes: 0,
|
|
||||||
comment: _c.body,
|
|
||||||
createdAt: calDateDiff(_c.created_at)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Reset input
|
|
||||||
comment = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upvoteQuestion(type: string, commentId?: string) {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
const isQuestion = type === 'question';
|
|
||||||
|
|
||||||
if (isQuestion && voted.question) return;
|
|
||||||
if (!isQuestion && commentId && voted.comment[commentId]) return;
|
|
||||||
|
|
||||||
const table = isQuestion ? 'community_question' : 'community_answer';
|
|
||||||
const matchId = isQuestion ? question.id : commentId;
|
|
||||||
let votes = 0;
|
|
||||||
|
|
||||||
if (isQuestion) {
|
|
||||||
question.votes = question.votes + 1;
|
|
||||||
votes = question.votes;
|
|
||||||
} else {
|
|
||||||
question.comments = question.comments.map((c) => {
|
|
||||||
if (c.id === commentId) {
|
|
||||||
c.votes = c.votes + 1;
|
|
||||||
votes = c.votes;
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { error } = await supabase.from(table).update({ votes }).match({ id: matchId });
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: upvoteQuestion', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
if (isQuestion) {
|
|
||||||
voted.question = true;
|
|
||||||
} else if (commentId) {
|
|
||||||
voted.comment[commentId] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleQuestionEdit() {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
if (isEditMode) {
|
|
||||||
errors = askCommunityValidation(editContent);
|
|
||||||
console.log('handleQuestionEdit errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isEditMode = !isEditMode;
|
|
||||||
editorInstance = !editorInstance;
|
|
||||||
|
|
||||||
if (!isEditMode) {
|
|
||||||
errors = askCommunityValidation(editContent);
|
|
||||||
console.log('handleQuestionEdit errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.update({
|
|
||||||
title: editContent.title,
|
|
||||||
body: editContent.body,
|
|
||||||
course_id: editContent.courseId
|
|
||||||
})
|
|
||||||
.match({ id: question.id });
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: handleQuestionEdit', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
question.title = editContent.title;
|
|
||||||
question.body = editContent.body;
|
|
||||||
question.courseId = editContent.courseId;
|
|
||||||
|
|
||||||
editContent.title = '';
|
|
||||||
editContent.body = '';
|
|
||||||
editContent.courseId = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editContent.title = question.title;
|
|
||||||
editContent.body = question.body;
|
|
||||||
editContent.courseId = question.courseId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(isQuestion: boolean) {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
if (!isQuestion) {
|
|
||||||
deleteComment.isDeleting = true;
|
|
||||||
|
|
||||||
const { error } = await supabase.from('community_answer').delete().match({ id: deleteComment.commentId });
|
|
||||||
|
|
||||||
deleteComment.isDeleting = false;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
snackbar.error('snackbar.community.error.deleting_comments');
|
|
||||||
console.log('Error deleting comments', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
snackbar.success('snackbar.community.success.success_delete');
|
|
||||||
|
|
||||||
question.comments = question.comments.filter((c) => c.id !== deleteComment.commentId);
|
|
||||||
deleteComment.shouldDelete = false;
|
|
||||||
deleteComment.commentId = '';
|
|
||||||
|
|
||||||
// Handle only delete comment
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteQuestion.isDeleting = true;
|
|
||||||
|
|
||||||
const { error: commentDeleteError } = await supabase
|
|
||||||
.from('community_answer')
|
|
||||||
.delete()
|
|
||||||
.match({ question_id: deleteQuestion.questionId });
|
|
||||||
|
|
||||||
if (commentDeleteError) {
|
|
||||||
snackbar.error('snackbar.community.error.deleting_comments');
|
|
||||||
console.log('Error deleting comments', commentDeleteError);
|
|
||||||
|
|
||||||
deleteQuestion.isDeleting = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: questionDeleteError } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.delete()
|
|
||||||
.match({ id: deleteQuestion.questionId });
|
|
||||||
|
|
||||||
if (questionDeleteError) {
|
|
||||||
snackbar.error('snackbar.community.error.deleting_question');
|
|
||||||
console.log('Error deleting question', questionDeleteError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
snackbar.success('snackbar.community.success.success_delete');
|
|
||||||
goto(`/lms/community`);
|
|
||||||
deleteQuestion.isDeleting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
fetchCommunityQuestion(slug);
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if ($profile.id && $currentOrg.id) {
|
|
||||||
getCourses($profile.id, $currentOrg.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear store when component unmounts
|
|
||||||
$effect(() => {
|
|
||||||
return () => {
|
|
||||||
currentCommunityQuestion.set({ title: null });
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{question?.title || 'Question'}</title>
|
<title>Question - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<CommunityDeleteModal
|
<CommunityQuestionPage slug={data.slug} isLMS={true} />
|
||||||
bind:open={deleteQuestion.shouldDelete}
|
|
||||||
isDeleting={deleteQuestion.isDeleting}
|
|
||||||
onCancel={() => {
|
|
||||||
deleteQuestion.shouldDelete = false;
|
|
||||||
deleteQuestion.questionId = '';
|
|
||||||
}}
|
|
||||||
onDelete={() => handleDelete(true)}
|
|
||||||
isQuestion={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !question}
|
|
||||||
<div class="mb-3 px-5 py-10">
|
|
||||||
<Skeleton class="h-4 w-[25%]" />
|
|
||||||
<Skeleton class="mb-2 h-4 w-full" />
|
|
||||||
<Skeleton class="h-80 w-full" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{@const currentQuestion = question}
|
|
||||||
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
|
|
||||||
<Page.Header>
|
|
||||||
<Page.HeaderContent>
|
|
||||||
{#if isEditMode}
|
|
||||||
<div class="flex w-full items-center gap-2">
|
|
||||||
<InputField bind:value={editContent.title} className="flex-1" errorMessage={errors.title} />
|
|
||||||
<Select.Root type="single" bind:value={editContent.courseId}>
|
|
||||||
<Select.Trigger class="h-full w-[25%]">
|
|
||||||
<p>
|
|
||||||
{editContent.courseId
|
|
||||||
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
|
|
||||||
: $t('community.ask.select_course')}
|
|
||||||
</p>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each fetchedCourses as course}
|
|
||||||
{#if course.id}
|
|
||||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Vote value={currentQuestion.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
|
|
||||||
<Page.Title>{currentQuestion.title}</Page.Title>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Page.HeaderContent>
|
|
||||||
{#if currentQuestion.author.id === $profile.id}
|
|
||||||
<Page.Action>
|
|
||||||
<Button variant="outline" onclick={handleQuestionEdit}>
|
|
||||||
{isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
|
|
||||||
</Button>
|
|
||||||
{#if isEditMode}
|
|
||||||
<Button variant="ghost" onclick={() => (isEditMode = !isEditMode)}>
|
|
||||||
{$t('community.ask.cancel')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Page.Action>
|
|
||||||
{/if}
|
|
||||||
</Page.Header>
|
|
||||||
<Page.Body>
|
|
||||||
{#snippet child()}
|
|
||||||
<div class="border-gray my-1 rounded-lg border px-1">
|
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
|
||||||
<div class="flex items-center text-black no-underline hover:underline">
|
|
||||||
<Avatar.Root class="h-7 w-7">
|
|
||||||
<Avatar.Image
|
|
||||||
src={currentQuestion.author.avatar ? currentQuestion.author.avatar : '/logo-192.png'}
|
|
||||||
alt={currentQuestion.author.name ? currentQuestion.author.name : 'User'}
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>{shortenName(currentQuestion.author.name) || 'U'}</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
<p class="ml-2 text-sm dark:text-white">{currentQuestion.author.name}</p>
|
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
|
||||||
{currentQuestion.createdAt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if currentQuestion.author.id === $profile.id || $isOrgAdmin}
|
|
||||||
<IconButton
|
|
||||||
onclick={() => {
|
|
||||||
deleteQuestion.shouldDelete = true;
|
|
||||||
deleteQuestion.questionId = currentQuestion.id;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon size={16} />
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
{#if isEditMode && editorInstance}
|
|
||||||
<div class="my-2">
|
|
||||||
<TextEditor
|
|
||||||
placeholder="Give an answer"
|
|
||||||
content={editContent.body}
|
|
||||||
onChange={(content) => (editContent.body = content)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<section class="prose prose-sm sm:prose p-2">
|
|
||||||
{@html currentQuestion.body}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-8">
|
|
||||||
{pluralize($t('community.answers'), currentQuestion.totalComments, true)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each currentQuestion.comments as comment}
|
|
||||||
<div class="my-5 flex items-start px-1">
|
|
||||||
<Vote
|
|
||||||
value={comment.votes}
|
|
||||||
upVote={() => upvoteQuestion('comment', comment.id)}
|
|
||||||
disabled={voted.comment[comment.id]}
|
|
||||||
/>
|
|
||||||
<div class="border-gray w-full rounded-lg border">
|
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
|
||||||
<div class="flex items-center text-black">
|
|
||||||
<Avatar.Root class="h-7 w-7">
|
|
||||||
<Avatar.Image
|
|
||||||
src={comment.avatar ? comment.avatar : '/logo-192.png'}
|
|
||||||
alt={comment.name ? comment.name : 'User'}
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
|
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
|
||||||
{comment.createdAt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isValidAnswer}
|
|
||||||
<CircleCheckIcon size={16} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if comment.authorId === $profile.id || $isOrgAdmin}
|
|
||||||
<IconButton
|
|
||||||
onclick={() => {
|
|
||||||
deleteComment.shouldDelete = true;
|
|
||||||
deleteComment.commentId = comment.id;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon size={16} />
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
<article class="prose prose-sm sm:prose p-2">
|
|
||||||
{@html comment.comment}
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
{#if !editorInstance}
|
|
||||||
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-2 flex justify-end">
|
|
||||||
<Button onclick={submitComment}>
|
|
||||||
{$t('community.ask.comment')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Page.Body>
|
|
||||||
</Page.Root>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,152 +1,22 @@
|
|||||||
<script lang="ts">
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import { AskCommunityPage } from '$features/community/pages';
|
||||||
import * as Select from '@cio/ui/base/select';
|
|
||||||
|
|
||||||
import type { Course } from '$lib/utils/types';
|
|
||||||
import { profile } from '$lib/utils/store/user';
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courses } from '$features/course/utils/store';
|
|
||||||
import { supabase } from '$lib/utils/functions/supabase';
|
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
|
||||||
import { fetchCourses } from '$lib/utils/services/courses';
|
|
||||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
|
||||||
import { askCommunityValidation } from '$lib/utils/functions/validator';
|
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
|
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
|
||||||
import { TextEditor } from '$features/ui';
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
|
||||||
let errors: {
|
|
||||||
title?: string;
|
|
||||||
courseId?: string;
|
|
||||||
body?: string;
|
|
||||||
} = $state({});
|
|
||||||
let fields = $state({
|
|
||||||
title: '',
|
|
||||||
body: '',
|
|
||||||
courseId: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasFetched = $state(false);
|
|
||||||
let fetchedCourses: Course[] = $state([]);
|
|
||||||
|
|
||||||
async function getCourses(userId: string | null, orgId: string) {
|
|
||||||
if (hasFetched) return;
|
|
||||||
|
|
||||||
if ($courses.length) {
|
|
||||||
fetchedCourses = [...$courses];
|
|
||||||
hasFetched = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coursesResults = await fetchCourses(userId, orgId);
|
|
||||||
if (!coursesResults) return;
|
|
||||||
|
|
||||||
fetchedCourses = coursesResults.allCourses;
|
|
||||||
hasFetched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
errors = askCommunityValidation(fields);
|
|
||||||
console.log('handleSave errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: question, error } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.insert({
|
|
||||||
title: fields.title,
|
|
||||||
body: fields.body,
|
|
||||||
organization_id: $currentOrg.id,
|
|
||||||
author_profile_id: $profile.id,
|
|
||||||
votes: 0,
|
|
||||||
slug: generateSlug(fields.title),
|
|
||||||
course_id: fields.courseId
|
|
||||||
})
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: asking question', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
const slug = question[0]?.slug;
|
|
||||||
|
|
||||||
console.log('Success: asking question', question, slug);
|
|
||||||
snackbar.success('snackbar.community.success.question_submitted');
|
|
||||||
goto(`/lms/community/${slug}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($profile.id && $currentOrg.id) {
|
|
||||||
getCourses($profile.id, $currentOrg.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Ask the Community - ClassroomIO</title>
|
<title>Ask the Community - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page.Root>
|
<Page.Root class="w-full">
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
|
||||||
<Button variant="default" onclick={handleSave}>
|
|
||||||
{$t('community.ask.publish')}
|
|
||||||
</Button>
|
|
||||||
</Page.Action>
|
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<div class="mb-3 flex justify-between gap-x-5">
|
<AskCommunityPage isLMS={true} />
|
||||||
<InputField
|
|
||||||
bind:value={fields.title}
|
|
||||||
placeholder={$t('community.ask.title')}
|
|
||||||
errorMessage={errors.title}
|
|
||||||
className="w-[75%]"
|
|
||||||
/>
|
|
||||||
<div class="w-[25%]">
|
|
||||||
<Select.Root type="single" bind:value={fields.courseId}>
|
|
||||||
<Select.Trigger class="h-full w-full">
|
|
||||||
<p>
|
|
||||||
{fields.courseId
|
|
||||||
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
|
|
||||||
: $t('community.ask.select_course')}
|
|
||||||
</p>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each fetchedCourses as course}
|
|
||||||
{#if course.id}
|
|
||||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
{#if errors.courseId}
|
|
||||||
<p class="mt-1 text-sm text-red-500">
|
|
||||||
{errors.courseId}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-2">
|
|
||||||
<TextEditor
|
|
||||||
placeholder="Give an answer"
|
|
||||||
content={fields.body}
|
|
||||||
onChange={(content) => (fields.body = content)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if errors.body}
|
|
||||||
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
</Page.Root>
|
</Page.Root>
|
||||||
|
|||||||
@@ -1,546 +1,11 @@
|
|||||||
<script lang="ts">
|
<script>
|
||||||
import { untrack } from 'svelte';
|
import { CommunityQuestionPage } from '$features/community/pages';
|
||||||
import pluralize from 'pluralize';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
|
||||||
import * as Select from '@cio/ui/base/select';
|
|
||||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
|
||||||
import { courses } from '$features/course/utils/store';
|
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
|
||||||
import { supabase } from '$lib/utils/functions/supabase';
|
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
|
||||||
import { fetchCourses } from '$lib/utils/services/courses';
|
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import { currentOrg, currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
|
||||||
import { askCommunityValidation, commentInCommunityValidation } from '$lib/utils/functions/validator';
|
|
||||||
|
|
||||||
import type { Course } from '$lib/utils/types';
|
|
||||||
import { profile } from '$lib/utils/store/user';
|
|
||||||
import { Vote } from '$features/ui';
|
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
|
||||||
import { TextEditor } from '$features/ui';
|
|
||||||
import { CommunityDeleteModal } from '$features/community/components';
|
|
||||||
import { CircleCheckIcon } from '$features/ui/icons';
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
|
||||||
|
|
||||||
interface Comment {
|
|
||||||
id: string;
|
|
||||||
authorId: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
votes: number;
|
|
||||||
comment: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
interface Question {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
votes: number;
|
|
||||||
author: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
comments: Comment[];
|
|
||||||
totalComments: number;
|
|
||||||
courseId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const { slug } = data;
|
|
||||||
|
|
||||||
let question: Question | undefined = $state();
|
|
||||||
let comment = $state('');
|
|
||||||
let errors: {
|
|
||||||
title?: string;
|
|
||||||
} = $state({});
|
|
||||||
let isValidAnswer = false; // V2 allow admin mark an answer as accepted
|
|
||||||
let voted: {
|
|
||||||
question: boolean;
|
|
||||||
comment: {
|
|
||||||
[key: string]: boolean;
|
|
||||||
};
|
|
||||||
} = $state({ question: false, comment: {} });
|
|
||||||
let isEditMode = $state(false);
|
|
||||||
let deleteComment = $state({
|
|
||||||
shouldDelete: false,
|
|
||||||
commentId: '',
|
|
||||||
isDeleting: false
|
|
||||||
});
|
|
||||||
let deleteQuestion = $state({
|
|
||||||
shouldDelete: false,
|
|
||||||
questionId: '',
|
|
||||||
isDeleting: false
|
|
||||||
});
|
|
||||||
let editContent = $state({
|
|
||||||
title: '',
|
|
||||||
body: '',
|
|
||||||
courseId: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
let editorInstance = $state(false);
|
|
||||||
let fetchedCourses: Course[] = $state([]);
|
|
||||||
|
|
||||||
function mapResToQuestion(data): Question {
|
|
||||||
return {
|
|
||||||
id: data.id,
|
|
||||||
title: data.title,
|
|
||||||
votes: data.votes,
|
|
||||||
author: {
|
|
||||||
id: data?.author?.id || '',
|
|
||||||
name: data?.author?.fullname || '',
|
|
||||||
avatar: data?.author?.avatar_url || ''
|
|
||||||
},
|
|
||||||
body: data.body,
|
|
||||||
createdAt: calDateDiff(data.created_at),
|
|
||||||
comments: data.comments.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
authorId: c.author?.id || '',
|
|
||||||
name: c.author?.fullname || '',
|
|
||||||
avatar: c.author?.avatar_url || '',
|
|
||||||
votes: c.votes,
|
|
||||||
comment: c.body,
|
|
||||||
createdAt: calDateDiff(c.created_at)
|
|
||||||
})),
|
|
||||||
totalComments: 0,
|
|
||||||
courseId: data.course_id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasFetched = $state(false);
|
|
||||||
function getCourses(userId: string | null, orgId: string) {
|
|
||||||
if (hasFetched) return;
|
|
||||||
|
|
||||||
untrack(async () => {
|
|
||||||
if ($courses.length) {
|
|
||||||
fetchedCourses = [...$courses];
|
|
||||||
hasFetched = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coursesResults = await fetchCourses(userId, orgId);
|
|
||||||
fetchedCourses = coursesResults?.allCourses || [];
|
|
||||||
|
|
||||||
hasFetched = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCommunityQuestion(slug: string) {
|
|
||||||
if (!slug) return;
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
votes,
|
|
||||||
created_at,
|
|
||||||
course_id,
|
|
||||||
slug,
|
|
||||||
comments:community_answer(
|
|
||||||
id,
|
|
||||||
body,
|
|
||||||
votes,
|
|
||||||
created_at,
|
|
||||||
author:profile(id, fullname, avatar_url)
|
|
||||||
),
|
|
||||||
author:profile(id, fullname, avatar_url),
|
|
||||||
course!inner (
|
|
||||||
title
|
|
||||||
)
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.eq('slug', slug)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('[ORG] Error loading community', error);
|
|
||||||
return goto(`${$currentOrgPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
untrack(() => {
|
|
||||||
question = mapResToQuestion(data);
|
|
||||||
question.totalComments = question.comments.length;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitComment() {
|
|
||||||
errors = commentInCommunityValidation({ comment });
|
|
||||||
console.log('submitComment errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length || !question) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('community_answer')
|
|
||||||
.insert({
|
|
||||||
id: undefined,
|
|
||||||
body: comment,
|
|
||||||
question_id: question.id,
|
|
||||||
author_profile_id: $profile.id,
|
|
||||||
votes: 0
|
|
||||||
})
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: commenting', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
console.log('Success: commenting', data);
|
|
||||||
|
|
||||||
snackbar.success('snackbar.community.success.comment_submitted');
|
|
||||||
|
|
||||||
// Add to comment
|
|
||||||
const _c = data?.[0];
|
|
||||||
question.comments = [
|
|
||||||
...question.comments,
|
|
||||||
{
|
|
||||||
id: _c.id,
|
|
||||||
authorId: $profile.id || '',
|
|
||||||
name: $profile?.fullname || '',
|
|
||||||
avatar: $profile?.avatarUrl || '',
|
|
||||||
votes: 0,
|
|
||||||
comment: _c.body,
|
|
||||||
createdAt: calDateDiff(_c.created_at)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Reset input
|
|
||||||
comment = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upvoteQuestion(type: string, commentId?: string) {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
const isQuestion = type === 'question';
|
|
||||||
|
|
||||||
if (isQuestion && voted.question) return;
|
|
||||||
if (!isQuestion && commentId && voted.comment[commentId]) return;
|
|
||||||
|
|
||||||
const table = isQuestion ? 'community_question' : 'community_answer';
|
|
||||||
const matchId = isQuestion ? question.id : commentId;
|
|
||||||
let votes = 0;
|
|
||||||
|
|
||||||
if (isQuestion) {
|
|
||||||
question.votes = question.votes + 1;
|
|
||||||
votes = question.votes;
|
|
||||||
} else {
|
|
||||||
question.comments = question.comments.map((c) => {
|
|
||||||
if (c.id === commentId) {
|
|
||||||
c.votes = c.votes + 1;
|
|
||||||
votes = c.votes;
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { error } = await supabase.from(table).update({ votes }).match({ id: matchId });
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: upvoteQuestion', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
if (isQuestion) {
|
|
||||||
voted.question = true;
|
|
||||||
} else if (commentId) {
|
|
||||||
voted.comment[commentId] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleQuestionEdit() {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
if (isEditMode) {
|
|
||||||
errors = askCommunityValidation(editContent);
|
|
||||||
console.log('handleQuestionEdit errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isEditMode = !isEditMode;
|
|
||||||
editorInstance = !editorInstance;
|
|
||||||
|
|
||||||
if (!isEditMode) {
|
|
||||||
errors = askCommunityValidation(editContent);
|
|
||||||
console.log('handleQuestionEdit errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.update({
|
|
||||||
title: editContent.title,
|
|
||||||
body: editContent.body,
|
|
||||||
course_id: editContent.courseId
|
|
||||||
})
|
|
||||||
.match({ id: question.id });
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: handleQuestionEdit', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
question.title = editContent.title;
|
|
||||||
question.body = editContent.body;
|
|
||||||
question.courseId = editContent.courseId;
|
|
||||||
|
|
||||||
editContent.title = '';
|
|
||||||
editContent.body = '';
|
|
||||||
editContent.courseId = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editContent.title = question.title;
|
|
||||||
editContent.body = question.body;
|
|
||||||
editContent.courseId = question.courseId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(isQuestion: boolean) {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
if (!isQuestion) {
|
|
||||||
deleteComment.isDeleting = true;
|
|
||||||
|
|
||||||
const { error } = await supabase.from('community_answer').delete().match({ id: deleteComment.commentId });
|
|
||||||
|
|
||||||
deleteComment.isDeleting = false;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
snackbar.error('snackbar.community.error.deleting_comments');
|
|
||||||
console.log('Error deleting comments', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
snackbar.success('snackbar.community.success.success_delete');
|
|
||||||
|
|
||||||
question.comments = question.comments.filter((c) => c.id !== deleteComment.commentId);
|
|
||||||
deleteComment.shouldDelete = false;
|
|
||||||
deleteComment.commentId = '';
|
|
||||||
|
|
||||||
// Handle only delete comment
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteQuestion.isDeleting = true;
|
|
||||||
|
|
||||||
const { error: commentDeleteError } = await supabase
|
|
||||||
.from('community_answer')
|
|
||||||
.delete()
|
|
||||||
.match({ question_id: deleteQuestion.questionId });
|
|
||||||
|
|
||||||
if (commentDeleteError) {
|
|
||||||
snackbar.error('snackbar.community.error.deleting_comments');
|
|
||||||
console.log('Error deleting comments', commentDeleteError);
|
|
||||||
|
|
||||||
deleteQuestion.isDeleting = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: questionDeleteError } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.delete()
|
|
||||||
.match({ id: deleteQuestion.questionId });
|
|
||||||
|
|
||||||
if (questionDeleteError) {
|
|
||||||
snackbar.error('snackbar.community.error.deleting_question');
|
|
||||||
console.log('Error deleting question', questionDeleteError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
snackbar.success('snackbar.community.success.success_delete');
|
|
||||||
goto(`${$currentOrgPath}/community`);
|
|
||||||
deleteQuestion.isDeleting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
fetchCommunityQuestion(slug);
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if ($profile.id && $currentOrg.id) {
|
|
||||||
getCourses($profile.id, $currentOrg.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{question?.title || $t('community.ask.question')}</title>
|
<title>Question - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<CommunityDeleteModal
|
<CommunityQuestionPage slug={data.slug} isLMS={false} />
|
||||||
bind:open={deleteComment.shouldDelete}
|
|
||||||
isDeleting={deleteComment.isDeleting}
|
|
||||||
onCancel={() => {
|
|
||||||
deleteComment.shouldDelete = false;
|
|
||||||
deleteComment.commentId = '';
|
|
||||||
}}
|
|
||||||
onDelete={() => handleDelete(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !question}
|
|
||||||
<div class="mb-3 px-5 py-10">
|
|
||||||
<Skeleton class="h-4 w-[25%]" />
|
|
||||||
<Skeleton class="mb-2 h-4 w-full" />
|
|
||||||
<Skeleton class="h-80 w-full" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
|
|
||||||
<Page.Header>
|
|
||||||
<Page.HeaderContent>
|
|
||||||
{#if isEditMode}
|
|
||||||
<div class="flex w-full items-center gap-2">
|
|
||||||
<InputField bind:value={editContent.title} className="flex-1" errorMessage={errors.title} />
|
|
||||||
<Select.Root type="single" bind:value={editContent.courseId}>
|
|
||||||
<Select.Trigger class="h-full w-[25%]">
|
|
||||||
<p>
|
|
||||||
{editContent.courseId
|
|
||||||
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
|
|
||||||
: $t('community.ask.select_course')}
|
|
||||||
</p>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each fetchedCourses as course}
|
|
||||||
<Select.Item value={course.id || ''}>{course.title}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Vote value={question.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
|
|
||||||
<Page.Title>{question.title}</Page.Title>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Page.HeaderContent>
|
|
||||||
{#if question.author.id === $profile.id}
|
|
||||||
<Page.Action>
|
|
||||||
<Button variant="outline" onclick={handleQuestionEdit}>
|
|
||||||
{isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
|
|
||||||
</Button>
|
|
||||||
{#if isEditMode}
|
|
||||||
<Button variant="ghost" onclick={() => (isEditMode = !isEditMode)}>
|
|
||||||
{$t('community.ask.cancel')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Page.Action>
|
|
||||||
{/if}
|
|
||||||
</Page.Header>
|
|
||||||
<Page.Body>
|
|
||||||
{#snippet child()}
|
|
||||||
<div class="border-gray my-1 rounded-lg border px-1">
|
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
|
||||||
<div class="flex items-center text-black no-underline hover:underline">
|
|
||||||
<Avatar.Root class="h-7 w-7">
|
|
||||||
<Avatar.Image
|
|
||||||
src={question.author.avatar ? question.author.avatar : '/logo-192.png'}
|
|
||||||
alt={question.author.name ? question.author.name : 'User'}
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>{shortenName(question.author.name) || 'U'}</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
|
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
|
||||||
{question.createdAt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if question.author.id === $profile.id || $isOrgAdmin}
|
|
||||||
<IconButton
|
|
||||||
onclick={() => {
|
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
deleteQuestion.shouldDelete = true;
|
|
||||||
deleteQuestion.questionId = question.id;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon size={16} />
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
{#if isEditMode && editorInstance}
|
|
||||||
<div class="my-2">
|
|
||||||
<TextEditor
|
|
||||||
placeholder="Give an answer"
|
|
||||||
content={editContent.body}
|
|
||||||
onChange={(content) => (editContent.body = content)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<section class="prose prose-sm sm:prose p-2">
|
|
||||||
{@html question.body}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-8">
|
|
||||||
{pluralize($t('community.answers'), question.totalComments, true)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each question.comments as comment}
|
|
||||||
<div class="my-5 flex items-start px-1">
|
|
||||||
<Vote
|
|
||||||
value={comment.votes}
|
|
||||||
upVote={() => upvoteQuestion('comment', comment.id)}
|
|
||||||
disabled={voted.comment[comment.id]}
|
|
||||||
/>
|
|
||||||
<div class="border-gray w-full rounded-lg border">
|
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
|
||||||
<div class="flex items-center text-black">
|
|
||||||
<Avatar.Root class="h-7 w-7">
|
|
||||||
<Avatar.Image
|
|
||||||
src={comment.avatar ? comment.avatar : '/logo-192.png'}
|
|
||||||
alt={comment.name ? comment.name : 'User'}
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
|
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
|
||||||
{comment.createdAt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isValidAnswer}
|
|
||||||
<CircleCheckIcon size={16} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if comment.authorId === $profile.id || $isOrgAdmin}
|
|
||||||
<IconButton
|
|
||||||
onclick={() => {
|
|
||||||
deleteComment.shouldDelete = true;
|
|
||||||
deleteComment.commentId = comment.id;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon size={16} />
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
<article class="prose prose-sm sm:prose p-2">
|
|
||||||
{@html comment.comment}
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
{#if !editorInstance}
|
|
||||||
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-2 flex justify-end">
|
|
||||||
<Button onclick={submitComment}>
|
|
||||||
{$t('community.ask.comment')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Page.Body>
|
|
||||||
</Page.Root>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,152 +1,22 @@
|
|||||||
<script lang="ts">
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import { AskCommunityPage } from '$features/community/pages';
|
||||||
import * as Select from '@cio/ui/base/select';
|
|
||||||
|
|
||||||
import type { Course } from '$lib/utils/types';
|
|
||||||
import { profile } from '$lib/utils/store/user';
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courses } from '$features/course/utils/store';
|
|
||||||
import { supabase } from '$lib/utils/functions/supabase';
|
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
|
||||||
import { fetchCourses } from '$lib/utils/services/courses';
|
|
||||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
|
||||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
|
||||||
import { askCommunityValidation } from '$lib/utils/functions/validator';
|
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
|
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
|
||||||
import { TextEditor } from '$features/ui';
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
|
||||||
let errors: {
|
|
||||||
title?: string;
|
|
||||||
courseId?: string;
|
|
||||||
body?: string;
|
|
||||||
} = $state({});
|
|
||||||
let fields = $state({
|
|
||||||
title: '',
|
|
||||||
body: '',
|
|
||||||
courseId: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasFetched = $state(false);
|
|
||||||
let fetchedCourses: Course[] = $state([]);
|
|
||||||
|
|
||||||
async function getCourses(userId: string | null, orgId: string) {
|
|
||||||
if (hasFetched) return;
|
|
||||||
|
|
||||||
if ($courses.length) {
|
|
||||||
fetchedCourses = [...$courses];
|
|
||||||
hasFetched = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coursesResults = await fetchCourses(userId, orgId);
|
|
||||||
if (!coursesResults) return;
|
|
||||||
|
|
||||||
fetchedCourses = coursesResults.allCourses;
|
|
||||||
hasFetched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
errors = askCommunityValidation(fields);
|
|
||||||
console.log('handleSave errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: question, error } = await supabase
|
|
||||||
.from('community_question')
|
|
||||||
.insert({
|
|
||||||
title: fields.title,
|
|
||||||
body: fields.body,
|
|
||||||
organization_id: $currentOrg.id,
|
|
||||||
author_profile_id: $profile.id,
|
|
||||||
votes: 0,
|
|
||||||
slug: generateSlug(fields.title),
|
|
||||||
course_id: fields.courseId
|
|
||||||
})
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error: asking question', error);
|
|
||||||
snackbar.error('snackbar.community.error.try_again');
|
|
||||||
} else {
|
|
||||||
const slug = question[0]?.slug;
|
|
||||||
|
|
||||||
console.log('Success: asking question', question, slug);
|
|
||||||
snackbar.success('snackbar.community.success.question_submitted');
|
|
||||||
goto(`${$currentOrgPath}/community/${slug}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($profile.id && $currentOrg.id) {
|
|
||||||
getCourses($profile.id, $currentOrg.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Ask the Community - ClassroomIO</title>
|
<title>Ask the Community - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page.Root>
|
<Page.Root class="w-full">
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
|
||||||
<Button variant="default" onclick={handleSave}>
|
|
||||||
{$t('community.ask.publish')}
|
|
||||||
</Button>
|
|
||||||
</Page.Action>
|
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<div class="mb-3 flex justify-between gap-x-5">
|
<AskCommunityPage isLMS={false} />
|
||||||
<InputField
|
|
||||||
bind:value={fields.title}
|
|
||||||
placeholder={$t('community.ask.title')}
|
|
||||||
errorMessage={errors.title}
|
|
||||||
className="w-[75%]"
|
|
||||||
/>
|
|
||||||
<div class="w-[25%]">
|
|
||||||
<Select.Root type="single" bind:value={fields.courseId}>
|
|
||||||
<Select.Trigger class="h-full w-full">
|
|
||||||
<p>
|
|
||||||
{fields.courseId
|
|
||||||
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
|
|
||||||
: $t('community.ask.select_course')}
|
|
||||||
</p>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each fetchedCourses as course}
|
|
||||||
{#if course.id}
|
|
||||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
{#if errors.courseId}
|
|
||||||
<p class="mt-1 text-sm text-red-500">
|
|
||||||
{errors.courseId}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-2">
|
|
||||||
<TextEditor
|
|
||||||
placeholder="Give an answer"
|
|
||||||
content={fields.body}
|
|
||||||
onChange={(content) => (fields.body = content)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if errors.body}
|
|
||||||
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
</Page.Root>
|
</Page.Root>
|
||||||
|
|||||||
+2
-4
@@ -10,11 +10,9 @@
|
|||||||
"clean:pnpm": "pnpm clean && pnpm store prune",
|
"clean:pnpm": "pnpm clean && pnpm store prune",
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
"release": "standard-version",
|
"release": "standard-version",
|
||||||
"dashboard:dev": "pnpm dev --filter=@cio/dashboard",
|
"dashboard:dev": "pnpm dev --filter=@cio/dashboard --filter=@cio/ui --filter=@cio/db --filter=@cio/utils",
|
||||||
"dashboard:dev-full": "pnpm dev --filter=@cio/dashboard --filter=@cio/ui --filter=@cio/db --filter=@cio/utils",
|
|
||||||
"dashboard:start": "pnpm start --filter @cio/dashboard",
|
"dashboard:start": "pnpm start --filter @cio/dashboard",
|
||||||
"api:dev": "pnpm dev --filter=@cio/api",
|
"api:dev": "pnpm dev --filter=@cio/api --filter=@cio/email --filter=@cio/db --filter=@cio/utils",
|
||||||
"api:dev-full": "pnpm dev --filter=@cio/api --filter=@cio/email --filter=@cio/db --filter=@cio/utils",
|
|
||||||
"api:start": "pnpm start --filter @cio/api",
|
"api:start": "pnpm start --filter @cio/api",
|
||||||
"supabase:push": "pnpx supabase link --project-ref $PROJECT_ID && pnpx supabase db push --include-all",
|
"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"
|
"storybook:dev": "pnpm dev --filter=@cio/storybook --filter=@cio/ui"
|
||||||
|
|||||||
@@ -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<number>`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<TCommunityQuestion>) {
|
||||||
|
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<TCommunityAnswer>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './community';
|
||||||
@@ -2,7 +2,7 @@ import * as schema from '@db/schema';
|
|||||||
|
|
||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { TNewCourse } from '@db/types';
|
import { TCourse, TNewCourse } from '@db/types';
|
||||||
import { db } from '@db/drizzle';
|
import { db } from '@db/drizzle';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,7 +10,7 @@ import { db } from '@db/drizzle';
|
|||||||
* @param siteName Organization site name
|
* @param siteName Organization site name
|
||||||
* @returns Array of courses with lesson counts and organization info
|
* @returns Array of courses with lesson counts and organization info
|
||||||
*/
|
*/
|
||||||
export const getCoursesBySiteName = async (siteName: string) => {
|
export const getCoursesBySiteName = async (siteName: string): Promise<TCourse[]> => {
|
||||||
try {
|
try {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.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<TCourse[]> => {
|
||||||
|
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<number>`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
|
* Gets lessons by organization siteName
|
||||||
* @param siteName Organization site name
|
* @param siteName Organization site name
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { db } from '@db/drizzle';
|
|
||||||
import { TNewGroup, TNewGroupmember } from '@db/types';
|
|
||||||
import * as schema from '@db/schema';
|
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) {
|
export async function createGroup(values: TNewGroup) {
|
||||||
try {
|
try {
|
||||||
return db.insert(schema.group).values(values).returning();
|
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'}`);
|
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<boolean> => {
|
||||||
|
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<number | null> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -243,6 +243,44 @@ export const isUserOrgAdmin = async (orgId: string, profileId: string): Promise<
|
|||||||
return result.length > 0;
|
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<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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)
|
* Gets organization team members (non-students)
|
||||||
* @param orgId Organization ID
|
* @param orgId Organization ID
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
<span class="ui:text-foreground ui:text-md">ClassroomIO</span>
|
<span class="ui:text-foreground ui:text-md">ClassroomIO</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<p class="ui:text-muted-foreground ui:text-sm">The Coure Platform That’s Actually <i>Easy To Use</i></p>
|
<p class="ui:text-muted-foreground ui:text-sm">The Course Platform That’s Actually <i>Easy To Use</i></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<typeof ZNewCommunityQuestion>;
|
||||||
|
export type TCreateCommunityQuestion = z.infer<typeof ZCreateCommunityQuestion>;
|
||||||
|
export type TCommunityQuestions = z.infer<typeof ZCommunityQuestions>;
|
||||||
|
export type TCommunityQuestion = z.infer<typeof ZCommunityQuestion>;
|
||||||
|
|
||||||
|
export type TCommunityComment = z.infer<typeof ZCommunityComment>;
|
||||||
|
export type TUpvotePost = z.infer<typeof ZUpvotePost>;
|
||||||
|
|
||||||
|
export type TCommunityCommentDelete = z.infer<typeof ZCommunityCommentDelete>;
|
||||||
|
export type TGetCommunity = z.infer<typeof ZGetCommunity>;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './community';
|
||||||
Reference in New Issue
Block a user