diff --git a/ARCHITECHTURE.md b/ARCHITECHTURE.md index e9811df1..893fe1f1 100644 --- a/ARCHITECHTURE.md +++ b/ARCHITECHTURE.md @@ -420,6 +420,8 @@ export * from './course'; **Location**: `apps/api/src/routes/{domain}/{entity}.ts` +> **📖 Route Naming Conventions**: For comprehensive guidelines on route naming, URL structure, HTTP methods, nested resources, and action endpoints, see [`apps/api/ROUTE_NAMING_BEST_PRACTICES.md`](apps/api/ROUTE_NAMING_BEST_PRACTICES.md). + ### Public API Standards Since routes may be exposed as public APIs, follow these standards rigorously: @@ -1308,33 +1310,6 @@ export async function createOrg(profileId: string, data: { name: string; siteNam --- -### Route Path Convention - -When adding routes to an existing router: - -- **GET `/`** - Fetch/list resources (e.g., `GET /account` - get account data) -- **GET `/{id}`** - Fetch single resource (e.g., `GET /course/{id}`) -- **POST `/`** - Create resource (e.g., `POST /course` - create course) -- **PUT `/{resource}`** - Update resource (e.g., `PUT /account/user` - update user) -- **DELETE `/{id}`** - Delete resource (e.g., `DELETE /course/{id}`) - -**Example router with multiple routes**: - -```typescript -export const accountRouter = new Hono() - .get('/', authMiddleware, async (c) => { - // GET /account - get account data - }) - .put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => { - // PUT /account/user - update user profile - }) - .put('/password', authMiddleware, zValidator('json', ZChangePassword), async (c) => { - // PUT /account/password - change password - }); -``` - ---- - ### Quick Reference Checklist When creating a new route, follow this checklist: @@ -1422,7 +1397,7 @@ If the route doesn't exist: If the route exists: - Add new endpoint to existing router -- Follow route path conventions +- Follow route naming conventions (see [`apps/api/ROUTE_NAMING_BEST_PRACTICES.md`](apps/api/ROUTE_NAMING_BEST_PRACTICES.md)) #### Step 4: Create Frontend API Method diff --git a/apps/api/ROUTE_NAMING_BEST_PRACTICES.md b/apps/api/ROUTE_NAMING_BEST_PRACTICES.md new file mode 100644 index 00000000..f1cffe40 --- /dev/null +++ b/apps/api/ROUTE_NAMING_BEST_PRACTICES.md @@ -0,0 +1,543 @@ +# API Route Naming and Structure Best Practices + +This guide outlines best practices for naming and structuring API routes, inspired by Stripe's API design principles. These conventions ensure consistency, predictability, and maintainability across all API endpoints. + +--- + +## Core Principles + +### 1. **RESTful Resource Naming** + +Use **plural nouns** for resource names, following REST conventions: + +```typescript +// ✅ GOOD - Plural nouns +GET /courses +POST /courses +GET /courses/:id +PUT /courses/:id +DELETE /courses/:id + +GET /organizations +POST /organizations +GET /organizations/:id + +// ❌ BAD - Singular nouns +GET /course +POST /course +GET /organization +``` + +**Why?** Resources represent collections. Even when fetching a single item, you're accessing it from a collection. + +--- + +### 2. **URL Structure Hierarchy** + +Organize routes hierarchically to reflect resource relationships: + +```typescript +// ✅ GOOD - Clear hierarchy +GET /organizations/:orgId/courses +POST /organizations/:orgId/courses +GET /organizations/:orgId/courses/:courseId +PUT /organizations/:orgId/courses/:courseId + +GET /courses/:courseId/lessons +POST /courses/:courseId/lessons +GET /courses/:courseId/lessons/:lessonId + +// ❌ BAD - Flat structure loses context +GET /courses (which org?) +GET /lessons (which course?) +``` + +**Exception:** Top-level resources that don't belong to a parent: + +```typescript +// ✅ OK - Top-level resources +GET /account +PUT /account/user +GET /account/subscription +``` + +--- + +### 3. **HTTP Method Semantics** + +Use HTTP methods correctly for their intended purpose: + +| Method | Purpose | Idempotent | Response Body | +|--------|---------|------------|---------------| +| `GET` | Retrieve resource(s) | ✅ Yes | Always | +| `POST` | Create new resource | ❌ No | Created resource | +| `PUT` | Replace entire resource | ✅ Yes | Updated resource | +| `PATCH` | Partial update | ✅ Yes | Updated resource | +| `DELETE` | Remove resource | ✅ Yes | Usually empty (204) | + +```typescript +// ✅ GOOD - Correct method usage +GET /courses/:id // Retrieve course +POST /courses // Create course +PUT /courses/:id // Replace entire course +PATCH /courses/:id // Partial update +DELETE /courses/:id // Delete course + +// ❌ BAD - Wrong method +POST /courses/:id/update // Should be PUT or PATCH +GET /courses/:id/delete // Should be DELETE +POST /courses/:id // Should be PUT/PATCH for updates +``` + +--- + +### 4. **Nested Resources** + +Nest resources when there's a clear parent-child relationship: + +```typescript +// ✅ GOOD - Nested resources +GET /courses/:courseId/lessons +POST /courses/:courseId/lessons +GET /courses/:courseId/lessons/:lessonId +PUT /courses/:courseId/lessons/:lessonId + +GET /organizations/:orgId/members +POST /organizations/:orgId/members +DELETE /organizations/:orgId/members/:memberId + +// ❌ BAD - Flat structure with IDs +GET /lessons?courseId=123 // Less clear hierarchy +POST /lessons?courseId=123 // Relationship not obvious +``` + +**When to nest:** +- ✅ Child resource only exists within parent context +- ✅ Child resource ID is unique within parent (e.g., `lessonId` within `courseId`) +- ✅ Operations typically scoped to parent + +**When NOT to nest:** +- ❌ Resource can exist independently +- ❌ Resource ID is globally unique +- ❌ Operations span multiple parents + +```typescript +// ✅ OK - Independent resource +GET /users/:userId // User exists independently +GET /courses/:courseId // Course exists independently + +// ✅ BAD - Unnecessary nesting +GET /organizations/:orgId/users/:userId // User is global, not org-specific +``` + +--- + +### 5. **Action Endpoints (Non-Resource Operations)** + +For operations that don't map to CRUD, use action verbs as sub-resources: + +```typescript +// ✅ GOOD - Action endpoints +POST /courses/:id/clone +POST /courses/:id/publish +POST /courses/:id/unpublish +POST /organizations/:id/invite +POST /subscriptions/:id/cancel +POST /subscriptions/:id/reactivate + +// ❌ BAD - Verbs in main path +POST /clone-course/:id +POST /publish-course/:id +GET /get-courses // Redundant verb +``` + +**Pattern:** `POST /resource/:id/action` + +**When to use:** +- ✅ State transitions (publish, unpublish, activate, cancel) +- ✅ Side effects (clone, duplicate, export) +- ✅ Complex operations that don't fit CRUD + +--- + +### 6. **Query Parameters** + +Use query parameters for filtering, sorting, pagination, and optional data: + +```typescript +// ✅ GOOD - Query parameters +GET /courses?status=published +GET /courses?orgId=123&status=published +GET /courses?page=1&limit=20 +GET /courses?sort=created_at&order=desc +GET /courses?search=javascript +GET /organizations?siteName=acme + +// ❌ BAD - Path parameters for filters +GET /courses/status/published +GET /courses/page/1 +GET /courses/search/javascript +``` + +**Common query parameter patterns:** + +| Purpose | Parameter | Example | +|---------|-----------|---------| +| Filtering | `status`, `type`, `role` | `?status=active&type=course` | +| Search | `q`, `search` | `?q=javascript` | +| Pagination | `page`, `limit`, `offset` | `?page=1&limit=20` | +| Sorting | `sort`, `order` | `?sort=created_at&order=desc` | +| Date range | `startDate`, `endDate` | `?startDate=2024-01-01` | +| Boolean flags | `include`, `expand` | `?include=lessons&expand=metadata` | + +--- + +### 7. **URL Parameter Naming** + +Use **camelCase** for URL parameters to match JavaScript conventions: + +```typescript +// ✅ GOOD - camelCase +GET /courses/:courseId +GET /organizations/:orgId +GET /users/:userId +GET /lessons/:lessonId + +// ❌ BAD - snake_case or kebab-case +GET /courses/:course_id +GET /courses/:course-id +``` + +**Exception:** When matching database column names exactly: + +```typescript +// ✅ OK - Matching database convention +GET /courses/:id // Generic ID +GET /courses?siteName=acme // Matches DB column +``` + +--- + +### 8. **Consistent Response Structure** + +Maintain consistent response formats across all endpoints: + +```typescript +// ✅ GOOD - Consistent structure +// Single resource +{ + "success": true, + "data": { ... } +} + +// Collection +{ + "success": true, + "data": [ ... ], + "pagination": { + "page": 1, + "limit": 20, + "total": 100, + "totalPages": 5 + } +} + +// Error +{ + "success": false, + "error": "Human-readable message", + "code": "ERROR_CODE", + "field": "fieldName" // Optional, for validation errors +} +``` + +--- + +### 9. **Versioning Strategy** + +Plan for API versioning from the start: + +```typescript +// Current (v1 implied) +.route('/courses', courseRouter) + +// Future versioning +.route('/v1/courses', courseRouterV1) +.route('/v2/courses', courseRouterV2) + +// Or use headers (Stripe style) +// X-API-Version: 2024-01-01 +``` + +**Recommendation:** Start without versioning, add `/v1/` prefix when breaking changes are needed. + +--- + +### 10. **Route Organization in Code** + +Organize routes by domain/resource: + +```typescript +// ✅ GOOD - Organized by resource +apps/api/src/routes/ + ├── course/ + │ ├── course.ts // Main course routes + │ ├── lesson.ts // Lesson routes + │ ├── clone.ts // Clone action + │ └── index.ts // Re-exports + ├── organization/ + │ ├── organization.ts // Main org routes + │ ├── team.ts // Team member routes + │ └── index.ts + └── account/ + ├── account.ts // Account routes + └── index.ts +``` + +**Route registration:** + +```typescript +// apps/api/src/app.ts +import { courseRouter } from '@api/routes/course'; +import { organizationRouter } from '@api/routes/organization'; +import { accountRouter } from '@api/routes/account'; + +export const app = new Hono() + .route('/course', courseRouter) // /course/* + .route('/organization', organizationRouter) // /organization/* + .route('/account', accountRouter); // /account/* +``` + +--- + +## Complete Examples + +### Example 1: Course Management + +```typescript +// apps/api/src/routes/course/course.ts +export const courseRouter = new Hono() + // List courses (with filters) + .get('/', authMiddleware, zValidator('query', ZListCourses), async (c) => { + // GET /course?orgId=123&status=published&page=1&limit=20 + }) + + // Get single course + .get('/:courseId', authMiddleware, zValidator('param', ZGetCourse), async (c) => { + // GET /course/:courseId + }) + + // Create course + .post('/', authMiddleware, zValidator('json', ZCreateCourse), async (c) => { + // POST /course + }) + + // Update course (full) + .put('/:courseId', authMiddleware, zValidator('json', ZUpdateCourse), async (c) => { + // PUT /course/:courseId + }) + + // Update course (partial) + .patch('/:courseId', authMiddleware, zValidator('json', ZPatchCourse), async (c) => { + // PATCH /course/:courseId + }) + + // Delete course + .delete('/:courseId', authMiddleware, zValidator('param', ZDeleteCourse), async (c) => { + // DELETE /course/:courseId + }) + + // Actions + .post('/:courseId/clone', authMiddleware, zValidator('json', ZCloneCourse), async (c) => { + // POST /course/:courseId/clone + }) + + .post('/:courseId/publish', authMiddleware, zValidator('param', ZPublishCourse), async (c) => { + // POST /course/:courseId/publish + }); +``` + +### Example 2: Nested Resources + +```typescript +// apps/api/src/routes/course/lesson.ts +export const lessonRouter = new Hono() + // List lessons for a course + .get('/', authMiddleware, zValidator('query', ZListLessons), async (c) => { + // GET /course/:courseId/lesson?page=1&limit=20 + }) + + // Get single lesson + .get('/:lessonId', authMiddleware, zValidator('param', ZGetLesson), async (c) => { + // GET /course/:courseId/lesson/:lessonId + }) + + // Create lesson + .post('/', authMiddleware, zValidator('json', ZCreateLesson), async (c) => { + // POST /course/:courseId/lesson + }) + + // Update lesson + .put('/:lessonId', authMiddleware, zValidator('json', ZUpdateLesson), async (c) => { + // PUT /course/:courseId/lesson/:lessonId + }) + + // Delete lesson + .delete('/:lessonId', authMiddleware, zValidator('param', ZDeleteLesson), async (c) => { + // DELETE /course/:courseId/lesson/:lessonId + }); + +// Register nested route +// apps/api/src/routes/course/course.ts +export const courseRouter = new Hono() + // ... course routes ... + .route('/:courseId/lesson', lessonRouter); // Nested under course +``` + +### Example 3: Organization with Team + +```typescript +// apps/api/src/routes/organization/organization.ts +export const organizationRouter = new Hono() + // List organizations + .get('/', authMiddleware, zValidator('query', ZListOrgs), async (c) => { + // GET /organization?siteName=acme + }) + + // Get organization + .get('/:orgId', authMiddleware, zValidator('param', ZGetOrg), async (c) => { + // GET /organization/:orgId + }) + + // Create organization + .post('/', authMiddleware, zValidator('json', ZCreateOrg), async (c) => { + // POST /organization + }) + + // Update organization + .put('/:orgId', authMiddleware, zValidator('json', ZUpdateOrg), async (c) => { + // PUT /organization/:orgId + }) + + // Team management (nested) + .get('/:orgId/team', authMiddleware, zValidator('param', ZGetTeam), async (c) => { + // GET /organization/:orgId/team + }) + + .post('/:orgId/team/invite', authMiddleware, zValidator('json', ZInviteTeam), async (c) => { + // POST /organization/:orgId/team/invite + }) + + .delete('/:orgId/team/:memberId', authMiddleware, zValidator('param', ZRemoveMember), async (c) => { + // DELETE /organization/:orgId/team/:memberId + }); +``` + +--- + +## Anti-Patterns to Avoid + +### ❌ Don't: Use verbs in resource names + +```typescript +// ❌ BAD +GET /get-courses +POST /create-course +POST /update-course/:id +GET /delete-course/:id + +// ✅ GOOD +GET /courses +POST /courses +PUT /courses/:id +DELETE /courses/:id +``` + +### ❌ Don't: Mix singular and plural + +```typescript +// ❌ BAD +GET /course +POST /courses +GET /organization +POST /organizations + +// ✅ GOOD +GET /courses +POST /courses +GET /organizations +POST /organizations +``` + +### ❌ Don't: Use query parameters for required identifiers + +```typescript +// ❌ BAD +GET /courses?id=123 +GET /courses?courseId=123 + +// ✅ GOOD +GET /courses/:courseId +GET /courses/123 +``` + +### ❌ Don't: Create deep nesting (>3 levels) + +```typescript +// ❌ BAD - Too deep +GET /organizations/:orgId/courses/:courseId/lessons/:lessonId/exercises/:exerciseId + +// ✅ GOOD - Flatten when appropriate +GET /exercises/:exerciseId?courseId=123&lessonId=456 +// Or use a different resource structure +GET /lessons/:lessonId/exercises/:exerciseId +``` + +### ❌ Don't: Use inconsistent naming + +```typescript +// ❌ BAD - Mixed conventions +GET /courses/:course_id +GET /organizations/:orgId +GET /users/:userId + +// ✅ GOOD - Consistent camelCase +GET /courses/:courseId +GET /organizations/:orgId +GET /users/:userId +``` + +--- + +## Checklist for New Routes + +When creating a new route, ensure: + +- [ ] Resource name is **plural** (e.g., `/courses`, not `/course`) +- [ ] URL parameters use **camelCase** (e.g., `:courseId`, not `:course_id`) +- [ ] HTTP method matches operation (GET=read, POST=create, PUT=replace, PATCH=update, DELETE=remove) +- [ ] Nested resources reflect parent-child relationships +- [ ] Action endpoints use `POST /resource/:id/action` pattern +- [ ] Query parameters used for filtering, pagination, sorting +- [ ] Route is registered in main `app.ts` +- [ ] Validation schema exists in `packages/utils/src/validation/` +- [ ] Route follows existing patterns in codebase +- [ ] Response format is consistent with other routes + +--- + +## Summary + +**Key Takeaways:** + +1. **Plural nouns** for resources (`/courses`, `/organizations`) +2. **camelCase** for parameters (`:courseId`, `:orgId`) +3. **Hierarchical nesting** for related resources +4. **HTTP methods** used semantically (GET, POST, PUT, PATCH, DELETE) +5. **Action verbs** as sub-resources for non-CRUD operations +6. **Query parameters** for filtering, pagination, sorting +7. **Consistent structure** across all endpoints +8. **Organized by domain** in code structure + +Following these conventions ensures your API is **predictable**, **maintainable**, and **developer-friendly**. + diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 2c651155..8f0ca48e 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const envSchema = z.object({ CLOUDFLARE_ACCESS_KEY: z.string().optional(), diff --git a/apps/api/src/routes/account/account.ts b/apps/api/src/routes/account/account.ts index 101bca8d..7cf31ab4 100644 --- a/apps/api/src/routes/account/account.ts +++ b/apps/api/src/routes/account/account.ts @@ -28,7 +28,7 @@ export const accountRouter = new Hono() return handleError(c, error, 'Failed to fetch account data'); } }) - .put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => { + .put('/profile', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => { const user = c.get('user'); try { diff --git a/apps/api/src/routes/course/clone.ts b/apps/api/src/routes/course/clone.ts deleted file mode 100644 index 773bf6b7..00000000 --- a/apps/api/src/routes/course/clone.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CloneCourseErrorResponse, CloneCourseResponse } from '@api/types/database'; - -import { Hono } from '@api/utils/hono'; -import { ZCourseClone } from '@cio/utils/validation/course'; -import { authMiddleware } from '@api/middlewares/auth'; -import { cloneCourse } from '@api/services/course/clone'; -import { zValidator } from '@hono/zod-validator'; - -export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => { - try { - const validatedData = c.req.valid('json'); - const { id, title, description, slug, organizationId } = validatedData; - - const user = c.get('user'); - - // Clone the course - const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId); - - const successResponse: CloneCourseResponse = { - success: true, - course: newCourse - }; - return c.json(successResponse, 200); - } catch (error) { - console.error('Error cloning course:', error); - const errorResponse: CloneCourseErrorResponse = { - error: 'Failed to clone course', - details: error instanceof Error ? error.message : 'Unknown error' - }; - return c.json(errorResponse, 500); - } -}); diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts index be1444dc..ae178515 100644 --- a/apps/api/src/routes/course/course.ts +++ b/apps/api/src/routes/course/course.ts @@ -1,49 +1,94 @@ -import { ZCertificateDownload, ZCourseDownloadContent } from '@cio/utils/validation/course'; +import { + ZCertificateDownload, + ZCourseClone, + ZCourseCloneParam, + ZCourseDownloadContent, + ZCourseDownloadParam +} from '@cio/utils/validation/course'; import { Hono } from '@api/utils/hono'; -import { cloneRouter } from '@api/routes/course/clone'; +import { authMiddleware } from '@api/middlewares/auth'; +import { cloneCourse } from '@api/services/course/clone'; import { generateCertificate } from '@api/utils/certificate'; import { generateCoursePdf } from '@api/utils/course'; +import { handleError } from '@api/utils/errors'; import { katexRouter } from '@api/routes/course/katex'; import { lessonRouter } from '@api/routes/course/lesson'; import { presignRouter } from '@api/routes/course/presign'; import { zValidator } from '@hono/zod-validator'; export const courseRouter = new Hono() - .post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => { - const validatedData = c.req.valid('json'); + .post( + '/:courseId/download/certificate', + zValidator('param', ZCourseDownloadParam), + zValidator('json', ZCertificateDownload), + async (c) => { + const validatedData = c.req.valid('json'); - const result = await generateCertificate(validatedData); + const result = await generateCertificate(validatedData); - c.header('Content-Type', 'application/pdf'); - c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`); + c.header('Content-Type', 'application/pdf'); + c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`); - return c.body( - new ReadableStream({ - start(controller) { - controller.enqueue(result); - controller.close(); - } - }) - ); - }) - .post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => { - const validatedData = c.req.valid('json'); + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(result); + controller.close(); + } + }) + ); + } + ) + .post( + '/:courseId/download/content', + zValidator('param', ZCourseDownloadParam), + zValidator('json', ZCourseDownloadContent), + async (c) => { + const validatedData = c.req.valid('json'); - const pdfBuffer = await generateCoursePdf(validatedData); + const pdfBuffer = await generateCoursePdf(validatedData); - c.header('Content-Type', 'application/pdf'); + c.header('Content-Type', 'application/pdf'); - return c.body( - new ReadableStream({ - start(controller) { - controller.enqueue(pdfBuffer); - controller.close(); - } - }) - ); - }) + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(pdfBuffer); + controller.close(); + } + }) + ); + } + ) + .post( + '/:courseId/clone', + authMiddleware, + zValidator('param', ZCourseCloneParam), + zValidator('json', ZCourseClone), + async (c) => { + try { + const { courseId } = c.req.valid('param'); + const validatedData = c.req.valid('json'); + const { title, description, slug, organizationId } = validatedData; + + const user = c.get('user'); + + // Clone the course + const newCourse = await cloneCourse(courseId, title, user.id, description, slug, organizationId); + + return c.json( + { + success: true, + course: newCourse + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to clone course'); + } + } + ) .route('/katex', katexRouter) .route('/lesson', lessonRouter) - .route('/presign', presignRouter) - .route('/clone', cloneRouter); + .route('/presign', presignRouter); diff --git a/apps/api/src/routes/course/katex.ts b/apps/api/src/routes/course/katex.ts index 8ea6e9a8..13670fe3 100644 --- a/apps/api/src/routes/course/katex.ts +++ b/apps/api/src/routes/course/katex.ts @@ -1,7 +1,7 @@ -import { Hono } from '@api/utils/hono'; +import * as z from 'zod'; +import { Hono } from '@api/utils/hono'; import katex from 'katex'; -import { z } from 'zod'; export const katexRouter = new Hono().get('/', async (c) => { try { diff --git a/apps/api/src/routes/dash/stats.ts b/apps/api/src/routes/dash/stats.ts index 4c6ad93d..9c8bd4ff 100644 --- a/apps/api/src/routes/dash/stats.ts +++ b/apps/api/src/routes/dash/stats.ts @@ -5,17 +5,17 @@ import { getOrganisationAnalytics } from '@api/services/dash'; import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; -export const dashAnalyticsRouter = new Hono().post( +export const dashAnalyticsRouter = new Hono().get( '/stats', authMiddleware, - zValidator('json', ZDashStats), + zValidator('query', ZDashStats), async (c) => { try { - const { orgId, siteName } = c.req.valid('json'); + const { orgId, siteName } = c.req.valid('query'); const result = await getOrganisationAnalytics(orgId, siteName); - return c.json({ success: true, data: result }, 201); + return c.json({ success: true, data: result }, 200); } catch (error) { return handleError(c, error, 'Failed to load organisation analytics'); } diff --git a/apps/api/src/routes/mail/mail.ts b/apps/api/src/routes/mail/mail.ts index f913c9b0..4ede1741 100644 --- a/apps/api/src/routes/mail/mail.ts +++ b/apps/api/src/routes/mail/mail.ts @@ -4,7 +4,7 @@ import { deliverEmail } from '@cio/email'; import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; -export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEmail), async (c) => { +export const mailRouter = new Hono().post('/', zValidator('json', ZDeliverEmail), async (c) => { const validatedData = c.req.valid('json'); try { diff --git a/apps/api/src/routes/media/media.ts b/apps/api/src/routes/media/media.ts index 19cba78c..357f4b08 100644 --- a/apps/api/src/routes/media/media.ts +++ b/apps/api/src/routes/media/media.ts @@ -5,7 +5,7 @@ import { authMiddleware } from '@api/middlewares/auth'; import { handleError } from '@api/utils/errors'; import { uploadImage } from '@api/services/media'; -export const mediaRouter = new Hono().post('/image/upload', authMiddleware, async (c) => { +export const mediaRouter = new Hono().post('/image', authMiddleware, async (c) => { try { const body = await c.req.parseBody(); diff --git a/apps/api/src/routes/onboarding/onboarding.ts b/apps/api/src/routes/onboarding/onboarding.ts index 0652b3c2..81736ee9 100644 --- a/apps/api/src/routes/onboarding/onboarding.ts +++ b/apps/api/src/routes/onboarding/onboarding.ts @@ -1,4 +1,4 @@ -import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding'; +import { ZOnboardingCreateOrg, ZOnboardingUpdateMetadata } from '@cio/utils/validation/onboarding'; import { completeOnboarding, createOrganizationWithOwner, updateUserOnboarding } from '@api/services/onboarding'; import { Hono } from '@api/utils/hono'; @@ -7,7 +7,7 @@ import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; export const onboardingRouter = new Hono() - .post('/step1', authMiddleware, zValidator('json', ZOnboardingStep1), async (c) => { + .post('/create-org', authMiddleware, zValidator('json', ZOnboardingCreateOrg), async (c) => { try { const user = c.get('user'); const { fullname, orgName, siteName } = c.req.valid('json'); @@ -19,7 +19,7 @@ export const onboardingRouter = new Hono() return handleError(c, error, 'Failed to create organization'); } }) - .post('/step2', authMiddleware, zValidator('json', ZOnboardingStep2), async (c) => { + .post('/update-metadata', authMiddleware, zValidator('json', ZOnboardingUpdateMetadata), async (c) => { try { const user = c.get('user'); const { goal, source, fullname } = c.req.valid('json'); diff --git a/apps/dashboard/src/lib/components/Buttons/VisitOrgSite.svelte b/apps/dashboard/src/lib/components/Buttons/VisitOrgSite.svelte index 0b0605d9..8d478494 100644 --- a/apps/dashboard/src/lib/components/Buttons/VisitOrgSite.svelte +++ b/apps/dashboard/src/lib/components/Buttons/VisitOrgSite.svelte @@ -17,7 +17,7 @@ - diff --git a/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte b/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte index a429da7a..1489ab99 100644 --- a/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte @@ -23,13 +23,14 @@ isLoading = true; try { - const response = await classroomio.course.download.certificate.$post({ + const response = await classroomio.course[':courseId']['download']['certificate']['$post']({ + param: { courseId: $course.id }, json: { theme: `${$course.certificate_theme}`, studentName: `${$profile.fullname}`, courseName: `${$course.title}`, courseDescription: `${$course.description}`, - orgLogoUrl: $currentOrg.avatar_url || `${$currentOrgDomain}/logo-512.png`, + orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`, orgName: `${$currentOrg.name}` } }); diff --git a/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts b/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts index f9e8b4e1..c06b134e 100644 --- a/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts +++ b/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts @@ -88,8 +88,8 @@ export class ProfileApi extends BaseApiWithErrors { return; } - await this.execute({ - requestFn: () => classroomio.account.user.$put({ json: profileUpdates }), + await this.execute({ + requestFn: () => classroomio.account.profile.$put({ json: profileUpdates }), logContext: 'updating profile', onSuccess: (response) => { profileStore.update((_profile) => ({ diff --git a/apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts b/apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts new file mode 100644 index 00000000..69d06b87 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts @@ -0,0 +1,80 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { copyCourseModal, copyCourseModalInitialState } from '../utils/store'; + +import type { TCourseClone } from '@cio/utils/validation/course'; +import { ZCourseClone } from '@cio/utils/validation/course'; +import { currentOrg } from '$lib/utils/store/org'; +import generateSlug from '$lib/utils/functions/generateSlug'; +import { get } from 'svelte/store'; +import { goto } from '$app/navigation'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { resolve } from '$app/paths'; +import { snackbar } from '$lib/components/Snackbar/store'; +import { t } from '$lib/utils/functions/translations'; + +/** + * API class for course operations + */ +export class CourseCloneApi extends BaseApiWithErrors { + /** + * Clones a course + * @param courseId The ID of the course to clone + * @param title Course title + * @param description Course description (optional) + * @returns The cloned course data or null on error + */ + async clone(courseId: string, title: string, description?: string) { + // Generate unique slug from the title + const slug = generateSlug(title); + + // Prepare data for validation + const data: TCourseClone = { + title, + description, + slug, + organizationId: get(currentOrg).id + }; + + // Client-side validation + const result = ZCourseClone.safeParse(data); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error, 'course'); + return null; + } + + await this.execute<(typeof classroomio.course)[':courseId']['clone']['$post']>({ + requestFn: () => + classroomio.course[':courseId'].clone.$post({ + param: { courseId }, + // @ts-expect-error - the json type is not inferred correctly + json: result.data + }), + logContext: 'cloning course', + onSuccess: (response) => { + this.errors = {}; + + copyCourseModal.set(copyCourseModalInitialState); + copyCourseModal.update((modal) => ({ ...modal, open: false })); + + // Navigate to the new course + goto(resolve(`/courses/${response.course.id}`, {})); + + snackbar.success(t.get('courses.copy_course.success.course_cloned')); + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error(result); + return; + } + if ('error' in result && 'field' in result) { + // Field-specific error automatically mapped to this.errors[field] + this.errors[result.field as string] = result.error; + } else if ('error' in result) { + snackbar.error(result.error); + } + } + }); + } +} + +export const courseCloneApi = new CourseCloneApi(); diff --git a/apps/dashboard/src/lib/features/course/api/index.ts b/apps/dashboard/src/lib/features/course/api/index.ts new file mode 100644 index 00000000..f189842b --- /dev/null +++ b/apps/dashboard/src/lib/features/course/api/index.ts @@ -0,0 +1 @@ +export * from './course-clone.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/copy-course-modal.svelte b/apps/dashboard/src/lib/features/course/components/copy-course-modal.svelte index 0a1b2a40..65697366 100644 --- a/apps/dashboard/src/lib/features/course/components/copy-course-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/copy-course-modal.svelte @@ -1,64 +1,23 @@