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 <best@classroomio.com>
This commit is contained in:
Adeyemi Adetunji
2025-12-22 19:57:27 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 9fb6c7cbaa
commit bc9e3b41ae
41 changed files with 2699 additions and 1598 deletions
+450 -117
View File
@@ -826,6 +826,107 @@ import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
- Use `apiKeyMiddleware` for API-key-only endpoints
- Use `authOrApiKeyMiddleware` for endpoints that accept both user sessions and API keys
#### 6. **Role-Based Authorization Middleware**
**Middleware Location Decision:**
Use **generic middleware** (`apps/api/src/middlewares/`) when:
- ✅ The middleware is reusable across multiple routes/domains
- ✅ The pattern is generic (e.g., "is user member of org?")
- ✅ The check doesn't depend on specific resource types
Use **route-specific middleware** (`apps/api/src/routes/{domain}/middlewares/`) when:
- ✅ The middleware is specific to one route file
- ✅ The check involves resource-specific data (e.g., "is user author of THIS question?")
- ✅ The pattern won't be reused elsewhere
**Important:** Never put authorization logic inline in route handlers. Always create middleware functions, even if they're only used once.
**Available Generic Middleware:**
1. **`orgMemberMiddleware`** - User must be a member of the organization (any role)
- Location: `apps/api/src/middlewares/org-member.ts`
- Use for: Organization-scoped operations
- Requires: `cio-org-id` header
2. **`orgTeamMemberMiddleware`** - User must be ADMIN or TUTOR in organization
- Location: `apps/api/src/middlewares/org-team-member.ts`
- Use for: Admin-only operations across organization
- Requires: `cio-org-id` header
3. **`orgAdminMiddleware`** - User must be ADMIN in organization
- Location: `apps/api/src/middlewares/org-admin.ts` (already exists)
- Use for: Admin-only operations
- Requires: `cio-org-id` header
4. **`courseMemberMiddleware`** - User must be a member of the course's group
- Location: `apps/api/src/middlewares/course-member.ts`
- Use for: Course-scoped operations
- Requires: `courseId` in request params or query
**Route-Specific Middleware Pattern:**
For endpoint-specific authorization, create middleware in the route's middleware folder:
```
apps/api/src/routes/community/
├── community.ts
└── middlewares/
├── question-author-or-team.ts
├── comment-author-or-team.ts
└── question-course-member.ts
```
**Example Route-Specific Middleware:**
```typescript
// apps/api/src/routes/community/middlewares/question-author-or-team.ts
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { getQuestionAuthorAndCourse } from '@cio/db/queries/community';
import { isUserCourseTeamMember } from '@cio/db/queries/group';
export const questionAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
const user = c.get('user');
const questionId = c.req.param('id');
const question = await getQuestionAuthorAndCourse(questionId);
if (!question) {
return c.json({ success: false, error: 'Question not found' }, 404);
}
const isAuthor = question.authorId === user.id;
const isTeamMember = await isUserCourseTeamMember(question.courseId, user.id);
if (!isAuthor && !isTeamMember) {
return c.json({ success: false, error: 'Unauthorized' }, 403);
}
await next();
};
```
**Using Route-Specific Middleware:**
```typescript
// apps/api/src/routes/community/community.ts
import { questionAuthorOrTeamMiddleware } from './middlewares/question-author-or-team';
export const communityRouter = new Hono()
.put('/:id', authMiddleware, questionAuthorOrTeamMiddleware, zValidator('param', ZGetCommunity), async (c) => {
// Handler logic
});
```
**Best Practices:**
- Always use `authMiddleware` first
- Extract IDs from validated params/body
- Return 404 if resource doesn't exist
- Return 403 if user lacks permission
- Use existing `ErrorCodes` enum
- Keep middleware focused and single-purpose
- Never inline authorization checks - always create middleware functions
### Complete Route Example
Routes should be thin - they handle HTTP concerns and delegate to services:
@@ -1699,10 +1800,117 @@ try {
### Complete Frontend Example
Example with **client-side validation** before API call:
**✅ CORRECT Pattern - API Class Handles Everything:**
```typescript
// apps/dashboard/src/lib/features/course/api/course.svelte.ts
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { ZCourseCreate } from '@cio/utils/validation/course';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
import { goto } from '$app/navigation';
class CourseApi extends BaseApiWithErrors {
async createCourse(fields: { title: string; slug: string; description?: string }) {
// 1. CLIENT-SIDE VALIDATION
const result = ZCourseCreate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'course');
return;
}
// 2. API CALL WITH COMPLETE SUCCESS/ERROR HANDLING
await this.execute<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
// 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">
import { classroomio } from '$lib/utils/services/api';
import { ZCourseCreate } from '@cio/utils/validation/course';
@@ -1714,30 +1922,27 @@ Example with **client-side validation** before API call:
let loading = $state(false);
const handleSubmit = async () => {
// ❌ Validation in component
loading = true;
errors = {};
// Client-side validation with entity-specific translations
const result = ZCourseCreate.safeParse(fields);
if (!result.success) {
// Map validation errors to translated messages
errors = mapZodErrorsToTranslations(result.error, 'course');
// errors.title = "Course title is required"
// errors.slug = "Course slug is required"
loading = false;
return;
}
try {
// ❌ Manual API call
const response = await classroomio.course.create.$post({
json: result.data // Send validated data
json: result.data
});
const data = await response.json();
// ❌ Manual error handling
if (!data.success) {
// Handle API error (e.g., duplicate slug)
if (data.field) {
errors[data.field] = data.error;
} else {
@@ -1747,7 +1952,7 @@ Example with **client-side validation** before API call:
return;
}
// Success - redirect to course
// ❌ Navigation in component
const { course } = data.data;
await goto(`/courses/${course.slug}`);
@@ -1759,74 +1964,117 @@ Example with **client-side validation** before API call:
}
};
</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)
When creating API abstractions in `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`, **always use the `execute` method** from `BaseApi` or `BaseApiWithErrors` classes. This provides automatic loading state management, error handling, and consistent response processing.
When creating API abstractions in `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`, **the API class should be a complete abstraction** that handles everything. Components should have NO business logic related to API calls.
**Important**:
- **`.svelte.ts` files** are for **client-side use only** (Svelte components). They use Svelte reactivity (`$state`, `$derived`) and manage component state. **Never** include API key logic in these files.
- **`.server.ts` files** are for **server-side use only** (`+layout.server.ts`, `+page.server.ts`, `+server.ts`). They contain static methods with API key authentication and are automatically excluded from client bundles by SvelteKit.
#### Using the `execute` Method
#### Critical Principle: API Classes Handle Everything
**Pattern:**
**❌ WRONG - Logic in Component:**
```typescript
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
// Component file - TOO MUCH LOGIC
async function handleSave() {
// ❌ Validation in component
errors = customValidation(fields);
if (Object.keys(errors).length) return;
class MyApi extends BaseApiWithErrors {
async updateItem(id: string, data: Partial<Item>) {
await this.execute<typeof classroomio.items[':id']['$put']>({
requestFn: () => classroomio.items[':id'].$put({
param: { id },
json: data
// ❌ Business logic in component (slug generation, defaults)
const data = await api.create({
...fields,
slug: generateSlug(fields.title),
votes: 0,
authorId: $profile.id
});
// ❌ Manual error/success handling
if (api.error) {
snackbar.error('Error occurred');
} else {
snackbar.success('Success!');
goto(`/path/${data.slug}`);
}
}
```
**✅ CORRECT - All Logic in API Class:**
```typescript
// Component file - THIN AND CLEAN
<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) => {
// Update local stores with response.data
itemsStore.update((items) => {
const index = items.findIndex((i) => i.id === id);
if (index !== -1 && response.data) {
items[index] = { ...items[index], ...response.data };
}
return items;
});
if (response.data) {
// Store the slug for navigation
this.questionSlug = response.data.slug;
// Show success message
snackbar.success('snackbar.community.success.question_submitted');
// Navigate to the question
goto(`${get(currentOrgPath)}/community/${response.data.slug}`);
// Mark as successful
this.success = true;
this.errors = {};
}
},
onError: (result) => {
// Optional: Custom error handling
if (typeof result === 'string') {
snackbar.error(result);
snackbar.error('snackbar.community.error.try_again');
return;
}
if ('error' in result) {
@@ -1837,54 +2085,106 @@ class MyApi extends BaseApiWithErrors {
}
}
export const myApi = new MyApi();
export const communityApi = new CommunityApi();
```
#### What API Classes Should Handle
**✅ API Class Responsibilities:**
1. **Client-side validation** using shared Zod schemas
2. **Loading state** (`this.isLoading` - automatic via `execute`)
3. **Error state** (`this.errors` for field errors, `this.error` for general errors)
4. **Success handling** (update stores, show snackbar, navigate)
5. **Error handling** (show snackbar, map validation errors)
6. **Business logic** (format data, generate IDs/slugs if needed client-side)
7. **Navigation** (redirect after success)
8. **State management** (update local stores with response data)
**❌ Component Responsibilities:**
1. ~~Validation~~ → API class handles this
2. ~~Error handling~~ → API class handles this
3. ~~Success callbacks~~ → API class handles this
4. ~~Navigation~~ → API class handles this
5. ~~Business logic~~ → API class or service layer handles this
**✅ Component Responsibilities:**
1. **Render UI** (forms, inputs, buttons)
2. **Bind to form fields** (`bind:value={fields.title}`)
3. **Call API method** (`await api.createQuestion(fields)`)
4. **Display loading state** (`{api.isLoading ? 'Loading...' : 'Submit'}`)
5. **Display field errors** (`{#if api.errors.title}<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:**
- **Always use `this.execute`** - Never call the RPC client directly
- **Always validate first** - Use Zod's `safeParse()` before API call
- **Type the execute call** with `typeof classroomio.{route}.{method}` for type safety
- **Use `requestFn`** - Wrap the RPC call in a function
- **Use `logContext`** - Provide descriptive context for error logging
- **Use `onSuccess`** - Update local stores/reactivity with response data
- **Use `onError`** - Optional custom error handling (validation errors are handled automatically by `BaseApiWithErrors`)
**Examples:**
```typescript
// Profile API - Simple update
await this.execute<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);
}
}
});
```
- **Use `onSuccess`** - Handle ALL success logic (stores, snackbar, navigation, state updates)
- **Use `onError`** - Handle ALL error logic (snackbar, validation errors)
- **Return early on validation failure** - Don't make API call if validation fails
**Benefits:**
- ✅ Automatic loading state management (`this.isLoading`)
@@ -1893,6 +2193,9 @@ await this.execute<typeof classroomio.onboarding.step1.$post>({
- ✅ Type-safe responses
- ✅ Centralized logging
- ✅ Easy to test and mock
- ✅ **Components stay thin and focused on UI**
- ✅ **All business logic in one place**
- ✅ **Single source of truth for validation**
### Server-Side API Calls
@@ -2040,35 +2343,65 @@ const result = await OrgPlanApiServer.createOrgPlan(planData);
#### ✅ DO
- **Use type-safe RPC client** for API calls (Hono RPC)
**API Class Design:**
- **Handle ALL logic in API classes** - validation, loading, errors, success/failure, navigation
- **Always use `execute` method** in API abstraction classes (never call RPC client directly)
- **Use `.server.ts` API classes** in server-side files (`+layout.server.ts`, `+page.server.ts`, `+server.ts`)
- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication
- **Use API key** for server-to-server communication (webhooks, external calls)
- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation`
- **Always validate first** - Use `ZodSchema.safeParse()` before making API calls
- **Handle success in `onSuccess`** - Update stores, show snackbar, navigate, reset errors
- **Handle errors in `onError`** - Show snackbar, map validation errors to `this.errors`
- **Extend `BaseApiWithErrors`** for forms (provides `this.errors` for field-level validation)
- **Extend `BaseApi`** for simple API calls (provides `this.error` for general errors)
- **Use entity-specific translations** when multiple entities share field names
```typescript
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
this.errors = mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
```
**Component Design:**
- **Keep components thin** - Only render UI and call API methods
- **Bind to API state** - Use `api.isLoading`, `api.errors.fieldName`, `api.success`
- **Call API methods directly** - No `handleSave()` or `handleSubmit()` functions with logic
- **Display field errors** - Show `api.errors.fieldName` next to form fields
- **Display loading states** - Disable buttons with `api.isLoading`
**Server-Side:**
- **Use `.server.ts` API classes** in server-side files (`+layout.server.ts`, `+page.server.ts`, `+server.ts`)
- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication
- **Use API key** for server-to-server communication (webhooks, external calls)
**General:**
- **Use type-safe RPC client** for API calls (Hono RPC)
- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation`
- **Provide generic translations** as fallbacks for shared fields (email, password)
- **Handle both API errors and network errors** separately
- **Show specific field errors** when validation or API returns them
- **Keep UI responsive** during API calls (loading states)
- **Optimistically update UI** when appropriate
- **Handle different HTTP status codes** appropriately
- **Show user-friendly, translated error messages**
#### ❌ DON'T
- **Call RPC client directly** in API classes (always use `execute` method)
- **Call RPC client directly in server-side files** - use `.server.ts` API classes instead
- **Use `.svelte.ts` API classes in server-side files** - they're for client-side Svelte components only
- **Include API key logic in `.svelte.ts` files** - API keys must only be in `.server.ts` files
**API Class Anti-Patterns:**
- **DON'T call RPC client directly** in API classes (always use `execute` method)
- **DON'T return data from API methods** - Handle success in `onSuccess` callback
- **DON'T let components handle validation** - Validate in API class before API call
- **DON'T let components handle navigation** - Navigate in `onSuccess` callback
- **DON'T let components handle snackbar messages** - Show snackbar in `onSuccess`/`onError`
- **DON'T skip client-side validation** - Always validate before making API call
- **DON'T make API call if validation fails** - Return early on validation errors
**Component Anti-Patterns:**
- **DON'T write `handleSave()` functions with business logic** - Call API method directly
- **DON'T manually check `if (api.error)`** - API class handles errors in `onError`
- **DON'T manually call `goto()`** - API class handles navigation in `onSuccess`
- **DON'T manually call `snackbar`** - API class handles snackbar in callbacks
- **DON'T pass business logic data** (like `authorId`, `votes`, `slug`) - API class or service handles this
- **DON'T perform validation in components** - API class validates before API call
**Server-Side:**
- **DON'T call RPC client directly in server-side files** - use `.server.ts` API classes instead
- **DON'T use `.svelte.ts` API classes in server-side files** - they're for client-side Svelte components only
- **DON'T include API key logic in `.svelte.ts` files** - API keys must only be in `.server.ts` files
**General:**
- Skip error handling assuming API calls always succeed
- Display raw API error messages without formatting
- Skip client-side validation (validate before API call for instant feedback)
- Hardcode error messages in schemas (use translation system)
- Use the same generic message for all entities (use entity-specific when needed)
- Make multiple simultaneous calls that should be atomic