feat: enhance API route naming conventions and structure
- Added comprehensive guidelines for API route naming and structure in `ROUTE_NAMING_BEST_PRACTICES.md`. - Updated `ARCHITECHTURE.md` to reference the new best practices document. - Refactored course-related routes to follow new naming conventions, including changes to endpoint paths and validation schemas. - Removed the deprecated course clone route and integrated cloning functionality directly into the course routes. - Adjusted various API methods to ensure consistent usage of HTTP methods and parameter structures across the application.
This commit is contained in:
+3
-28
@@ -420,6 +420,8 @@ export * from './course';
|
|||||||
|
|
||||||
**Location**: `apps/api/src/routes/{domain}/{entity}.ts`
|
**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
|
### Public API Standards
|
||||||
|
|
||||||
Since routes may be exposed as public APIs, follow these standards rigorously:
|
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
|
### Quick Reference Checklist
|
||||||
|
|
||||||
When creating a new route, follow this checklist:
|
When creating a new route, follow this checklist:
|
||||||
@@ -1422,7 +1397,7 @@ If the route doesn't exist:
|
|||||||
|
|
||||||
If the route exists:
|
If the route exists:
|
||||||
- Add new endpoint to existing router
|
- 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
|
#### Step 4: Create Frontend API Method
|
||||||
|
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
|
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const accountRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to fetch account data');
|
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');
|
const user = c.get('user');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
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 { 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 { generateCertificate } from '@api/utils/certificate';
|
||||||
import { generateCoursePdf } from '@api/utils/course';
|
import { generateCoursePdf } from '@api/utils/course';
|
||||||
|
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 { 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('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
|
.post(
|
||||||
|
'/:courseId/download/certificate',
|
||||||
|
zValidator('param', ZCourseDownloadParam),
|
||||||
|
zValidator('json', ZCertificateDownload),
|
||||||
|
async (c) => {
|
||||||
const validatedData = c.req.valid('json');
|
const validatedData = c.req.valid('json');
|
||||||
|
|
||||||
const result = await generateCertificate(validatedData);
|
const result = await generateCertificate(validatedData);
|
||||||
@@ -26,8 +38,13 @@ export const courseRouter = new Hono()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
}
|
||||||
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
|
)
|
||||||
|
.post(
|
||||||
|
'/:courseId/download/content',
|
||||||
|
zValidator('param', ZCourseDownloadParam),
|
||||||
|
zValidator('json', ZCourseDownloadContent),
|
||||||
|
async (c) => {
|
||||||
const validatedData = c.req.valid('json');
|
const validatedData = c.req.valid('json');
|
||||||
|
|
||||||
const pdfBuffer = await generateCoursePdf(validatedData);
|
const pdfBuffer = await generateCoursePdf(validatedData);
|
||||||
@@ -42,8 +59,36 @@ export const courseRouter = new Hono()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
.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('/katex', katexRouter)
|
||||||
.route('/lesson', lessonRouter)
|
.route('/lesson', lessonRouter)
|
||||||
.route('/presign', presignRouter)
|
.route('/presign', presignRouter);
|
||||||
.route('/clone', cloneRouter);
|
|
||||||
|
|||||||
@@ -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 katex from 'katex';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const katexRouter = new Hono().get('/', async (c) => {
|
export const katexRouter = new Hono().get('/', async (c) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import { getOrganisationAnalytics } from '@api/services/dash';
|
|||||||
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 dashAnalyticsRouter = new Hono().post(
|
export const dashAnalyticsRouter = new Hono().get(
|
||||||
'/stats',
|
'/stats',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
zValidator('json', ZDashStats),
|
zValidator('query', ZDashStats),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const { orgId, siteName } = c.req.valid('json');
|
const { orgId, siteName } = c.req.valid('query');
|
||||||
|
|
||||||
const result = await getOrganisationAnalytics(orgId, siteName);
|
const result = await getOrganisationAnalytics(orgId, siteName);
|
||||||
|
|
||||||
return c.json({ success: true, data: result }, 201);
|
return c.json({ success: true, data: result }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to load organisation analytics');
|
return handleError(c, error, 'Failed to load organisation analytics');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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('/send', zValidator('json', ZDeliverEmail), async (c) => {
|
export const mailRouter = new Hono().post('/', zValidator('json', ZDeliverEmail), async (c) => {
|
||||||
const validatedData = c.req.valid('json');
|
const validatedData = c.req.valid('json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { authMiddleware } from '@api/middlewares/auth';
|
|||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { uploadImage } from '@api/services/media';
|
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 {
|
try {
|
||||||
const body = await c.req.parseBody();
|
const body = await c.req.parseBody();
|
||||||
|
|
||||||
|
|||||||
@@ -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 { completeOnboarding, createOrganizationWithOwner, updateUserOnboarding } from '@api/services/onboarding';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
@@ -7,7 +7,7 @@ import { handleError } from '@api/utils/errors';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const onboardingRouter = new Hono()
|
export const onboardingRouter = new Hono()
|
||||||
.post('/step1', authMiddleware, zValidator('json', ZOnboardingStep1), async (c) => {
|
.post('/create-org', authMiddleware, zValidator('json', ZOnboardingCreateOrg), async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const { fullname, orgName, siteName } = c.req.valid('json');
|
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');
|
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 {
|
try {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const { goal, source, fullname } = c.req.valid('json');
|
const { goal, source, fullname } = c.req.valid('json');
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
|
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
|
||||||
<Button variant="outline">
|
<Button variant="default">
|
||||||
{#if !$isMobile}
|
{#if !$isMobile}
|
||||||
{#if isLMS}
|
{#if isLMS}
|
||||||
{$t('dashboard.visit_site')}
|
{$t('dashboard.visit_site')}
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
{$t('settings.subheadings.view_site')}
|
{$t('settings.subheadings.view_site')}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<ArrowUpRightIcon />
|
<ArrowUpRightIcon class="custom" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+3
-2
@@ -23,13 +23,14 @@
|
|||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const response = await classroomio.course.download.certificate.$post({
|
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||||
|
param: { courseId: $course.id },
|
||||||
json: {
|
json: {
|
||||||
theme: `${$course.certificate_theme}`,
|
theme: `${$course.certificate_theme}`,
|
||||||
studentName: `${$profile.fullname}`,
|
studentName: `${$profile.fullname}`,
|
||||||
courseName: `${$course.title}`,
|
courseName: `${$course.title}`,
|
||||||
courseDescription: `${$course.description}`,
|
courseDescription: `${$course.description}`,
|
||||||
orgLogoUrl: $currentOrg.avatar_url || `${$currentOrgDomain}/logo-512.png`,
|
orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`,
|
||||||
orgName: `${$currentOrg.name}`
|
orgName: `${$currentOrg.name}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export class ProfileApi extends BaseApiWithErrors {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.execute<typeof classroomio.account.user.$put>({
|
await this.execute<typeof classroomio.account.profile.$put>({
|
||||||
requestFn: () => classroomio.account.user.$put({ json: profileUpdates }),
|
requestFn: () => classroomio.account.profile.$put({ json: profileUpdates }),
|
||||||
logContext: 'updating profile',
|
logContext: 'updating profile',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
profileStore.update((_profile) => ({
|
profileStore.update((_profile) => ({
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './course-clone.svelte';
|
||||||
@@ -1,64 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||||
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
import Modal from '$lib/components/Modal/index.svelte';
|
import Modal from '$lib/components/Modal/index.svelte';
|
||||||
import { snackbar } from '$lib/components/Snackbar/store';
|
|
||||||
import TextField from '$lib/components/Form/TextField.svelte';
|
import TextField from '$lib/components/Form/TextField.svelte';
|
||||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||||
import { copyCourseModalInitialState, copyCourseModal } from '$lib/features/course/utils/store';
|
import { copyCourseModal } from '$lib/features/course/utils/store';
|
||||||
import { classroomio } from '$lib/utils/services/api';
|
import { courseCloneApi } from '$lib/features/course/api';
|
||||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
// clone, show spinner and redirect to new course
|
|
||||||
async function createCourse() {
|
async function createCourse() {
|
||||||
// Prevent double submission (Option 1: Early Return Guard)
|
if ($copyCourseModal.isSaving || courseCloneApi.isLoading) return;
|
||||||
if ($copyCourseModal.isSaving) return;
|
|
||||||
|
|
||||||
console.log('Create course');
|
await courseCloneApi.clone($copyCourseModal.id, $copyCourseModal.title, $copyCourseModal.description);
|
||||||
$copyCourseModal.isSaving = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate unique slug from the title
|
|
||||||
const slug = generateSlug($copyCourseModal.title);
|
|
||||||
|
|
||||||
const response = await classroomio.course.clone.$post({
|
|
||||||
json: {
|
|
||||||
id: $copyCourseModal.id,
|
|
||||||
title: $copyCourseModal.title,
|
|
||||||
description: $copyCourseModal.description,
|
|
||||||
slug,
|
|
||||||
organizationId: $currentOrg.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync loading state with modal store
|
||||||
|
$effect(() => {
|
||||||
|
$copyCourseModal.isSaving = courseCloneApi.isLoading;
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option 3: Close modal and reset state before navigation
|
|
||||||
copyCourseModal.set(copyCourseModalInitialState);
|
|
||||||
|
|
||||||
// Navigate to the new course
|
|
||||||
goto(`/courses/${result.course.id}`);
|
|
||||||
$copyCourseModal.open = false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
// IIFI of snackbar to Show notification error
|
|
||||||
(() => {
|
|
||||||
snackbar.error();
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Stop loader
|
|
||||||
$copyCourseModal.isSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -76,6 +35,7 @@
|
|||||||
className="mb-4"
|
className="mb-4"
|
||||||
isRequired={true}
|
isRequired={true}
|
||||||
autoComplete={false}
|
autoComplete={false}
|
||||||
|
errorMessage={courseCloneApi.errors.title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
@@ -84,8 +44,13 @@
|
|||||||
placeholder={$t('courses.copy_course.course_description_placeholder')}
|
placeholder={$t('courses.copy_course.course_description_placeholder')}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
rows={4}
|
rows={4}
|
||||||
|
errorMessage={courseCloneApi.errors.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if courseCloneApi.errors.general}
|
||||||
|
<div class="mb-4 text-sm text-red-600">{courseCloneApi.errors.general}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-5 flex flex-row-reverse items-center">
|
<div class="mt-5 flex flex-row-reverse items-center">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
className="px-6 py-3"
|
className="px-6 py-3"
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export class OnboardingApi extends BaseApiWithErrors {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.execute<typeof classroomio.onboarding.step1.$post>({
|
await this.execute<(typeof classroomio.onboarding)['create-org']['$post']>({
|
||||||
requestFn: () => classroomio.onboarding.step1.$post({ json: data }),
|
requestFn: () => classroomio.onboarding['create-org'].$post({ json: data }),
|
||||||
logContext: 'submitting organization setup',
|
logContext: 'submitting organization setup',
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
const { organizations } = result.data;
|
const { organizations } = result.data;
|
||||||
@@ -70,8 +70,8 @@ export class OnboardingApi extends BaseApiWithErrors {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.execute<typeof classroomio.onboarding.step2.$post>({
|
await this.execute<(typeof classroomio.onboarding)['update-metadata']['$post']>({
|
||||||
requestFn: () => classroomio.onboarding.step2.$post({ json: data }),
|
requestFn: () => classroomio.onboarding['update-metadata'].$post({ json: data }),
|
||||||
logContext: 'submitting organization setup',
|
logContext: 'submitting organization setup',
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
profile.set(result.data);
|
profile.set(result.data);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OnboardingField, OnboardingStep } from './types';
|
import type { OnboardingField, OnboardingStep } from './types';
|
||||||
import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding';
|
import { ZOnboardingCreateOrg, ZOnboardingUpdateMetadata } from '@cio/utils/validation/onboarding';
|
||||||
|
|
||||||
import { ONBOARDING_STEPS } from './constants';
|
import { ONBOARDING_STEPS } from './constants';
|
||||||
import { blockedSubdomain } from '@cio/utils/constants';
|
import { blockedSubdomain } from '@cio/utils/constants';
|
||||||
@@ -11,14 +11,14 @@ export const onboardingValidation = (field: OnboardingField, step: OnboardingSte
|
|||||||
return { siteName: 'Sitename already exists.' };
|
return { siteName: 'Sitename already exists.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = ZOnboardingStep1.safeParse(field);
|
const result = ZOnboardingCreateOrg.safeParse(field);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return mapZodErrorsToTranslations(result.error);
|
return mapZodErrorsToTranslations(result.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === ONBOARDING_STEPS.USER_METADATA) {
|
if (step === ONBOARDING_STEPS.USER_METADATA) {
|
||||||
const result = ZOnboardingStep2.safeParse(field);
|
const result = ZOnboardingUpdateMetadata.safeParse(field);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return mapZodErrorsToTranslations(result.error);
|
return mapZodErrorsToTranslations(result.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ class DashStatApi extends BaseApi {
|
|||||||
* @returns Organization analytics data (enrollments, courses, revenue, students, top courses)
|
* @returns Organization analytics data (enrollments, courses, revenue, students, top courses)
|
||||||
*/
|
*/
|
||||||
async fetchOrgStats({ orgId, siteName }: { orgId?: string; siteName?: string }) {
|
async fetchOrgStats({ orgId, siteName }: { orgId?: string; siteName?: string }) {
|
||||||
await this.execute<typeof classroomio.dash.stats.$post>({
|
await this.execute<typeof classroomio.dash.stats.$get>({
|
||||||
requestFn: () => classroomio.dash.stats.$post({ json: { orgId, siteName } }),
|
requestFn: () => classroomio.dash.stats.$get({ query: { orgId, siteName } }),
|
||||||
logContext: 'fetching organization stats',
|
logContext: 'fetching organization stats',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
this.stats = response.data;
|
this.stats = response.data;
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ class OrgApi extends BaseApiWithErrors {
|
|||||||
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
|
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
|
||||||
return this.execute<InviteTeamResponse>({
|
return this.execute<InviteTeamResponse>({
|
||||||
requestFn: () =>
|
requestFn: () =>
|
||||||
classroomio.organization[':orgId'].team.invite.$post({
|
classroomio.organization[':orgId']['team']['invite']['$post']({
|
||||||
param: { orgId },
|
param: { orgId },
|
||||||
// @ts-expect-error - the json type is not inferred correctly
|
// @ts-expect-error - the json type is not inferred correctly
|
||||||
json: { emails, roleId }
|
json: { emails, roleId }
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
|
|||||||
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
|
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
|
||||||
|
|
||||||
// dashboard analytics types
|
// dashboard analytics types
|
||||||
export type GetDashStatsResponse = typeof classroomio.dash.stats.$post;
|
export type GetDashStatsResponse = typeof classroomio.dash.stats.$get;
|
||||||
export type DashStatsResponse = InferResponseType<GetDashStatsResponse> | null;
|
export type DashStatsResponse = InferResponseType<GetDashStatsResponse> | null;
|
||||||
export type DashStatsSuccess = Extract<InferResponseType<GetDashStatsResponse>, { success: true }>;
|
export type DashStatsSuccess = Extract<InferResponseType<GetDashStatsResponse>, { success: true }>;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { classroomio } from './api';
|
|||||||
*/
|
*/
|
||||||
export async function uploadImage(file: File): Promise<string> {
|
export async function uploadImage(file: File): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await classroomio.media.image.upload.$post({
|
const response = await classroomio.media.image.$post({
|
||||||
form: {
|
form: {
|
||||||
file: file
|
file: file
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const sendEmail = (sFetch: typeof fetch) => {
|
|||||||
}[]
|
}[]
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await classroomio.mail.send.$post({ json: emailDataArray }, { fetch: sFetch });
|
const response = await classroomio.mail.$post({ json: emailDataArray }, { fetch: sFetch });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log('Failed to send emails');
|
console.log('Failed to send emails');
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ await send('forgotPassword', {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// packages/email/src/emails/forgot-password.ts
|
// packages/email/src/emails/forgot-password.ts
|
||||||
import { z } from 'zod';
|
import * as z from 'zod';
|
||||||
import { defineEmail } from '../send';
|
import { defineEmail } from '../send';
|
||||||
import { getDefaultTemplate } from '../templates';
|
import { getDefaultTemplate } from '../templates';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
SMTP_HOST: z.string().optional(),
|
SMTP_HOST: z.string().optional(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { defineEmail } from '../send';
|
import { defineEmail } from '../send';
|
||||||
import { getDefaultTemplate } from '../templates';
|
import { getDefaultTemplate } from '../templates';
|
||||||
|
|
||||||
@@ -23,4 +24,3 @@ export const inviteTeacherEmail = defineEmail({
|
|||||||
return getDefaultTemplate(content);
|
return getDefaultTemplate(content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -160,7 +160,7 @@ validations.{fieldName}.{errorCode}
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// packages/utils/src/validation/lesson/lesson.ts
|
// packages/utils/src/validation/lesson/lesson.ts
|
||||||
import { z } from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lesson Create Validation Schema
|
* Lesson Create Validation Schema
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
export const ZUpdateProfile = z.object({
|
export const ZUpdateProfile = z.object({
|
||||||
fullname: z.string().min(5).optional(),
|
fullname: z.string().min(5).optional(),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
|
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const ZCourseClone = z.object({
|
export const ZCourseClone = z.object({
|
||||||
id: z.string().min(1),
|
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
slug: z.string().min(1),
|
slug: z.string().min(1),
|
||||||
@@ -11,6 +10,16 @@ export const ZCourseClone = z.object({
|
|||||||
});
|
});
|
||||||
export type TCourseClone = z.infer<typeof ZCourseClone>;
|
export type TCourseClone = z.infer<typeof ZCourseClone>;
|
||||||
|
|
||||||
|
export const ZCourseCloneParam = z.object({
|
||||||
|
courseId: z.string().min(1)
|
||||||
|
});
|
||||||
|
export type TCourseCloneParam = z.infer<typeof ZCourseCloneParam>;
|
||||||
|
|
||||||
|
export const ZCourseDownloadParam = z.object({
|
||||||
|
courseId: z.string().min(1)
|
||||||
|
});
|
||||||
|
export type TCourseDownloadParam = z.infer<typeof ZCourseDownloadParam>;
|
||||||
|
|
||||||
export const ZCertificateDownload = z.object({
|
export const ZCertificateDownload = z.object({
|
||||||
theme: z.string().min(1),
|
theme: z.string().min(1),
|
||||||
studentName: z.string().min(1),
|
studentName: z.string().min(1),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
export const ZEmailData = z.object({
|
export const ZEmailData = z.object({
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { ALLOWED_IMAGE_TYPES } from '../constants';
|
import { ALLOWED_IMAGE_TYPES } from '../constants';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for image upload presign URL request
|
* Schema for image upload presign URL request
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as z from 'zod';
|
|||||||
|
|
||||||
const fullnameValidation = z.string().min(5);
|
const fullnameValidation = z.string().min(5);
|
||||||
|
|
||||||
export const ZOnboardingStep1 = z.object({
|
export const ZOnboardingCreateOrg = z.object({
|
||||||
fullname: fullnameValidation,
|
fullname: fullnameValidation,
|
||||||
orgName: z
|
orgName: z
|
||||||
.string()
|
.string()
|
||||||
@@ -17,11 +17,11 @@ export const ZOnboardingStep1 = z.object({
|
|||||||
message: 'validations.site_name.hyphen_rule'
|
message: 'validations.site_name.hyphen_rule'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
export type TOnboardingStep1 = z.infer<typeof ZOnboardingStep1>;
|
export type TOnboardingCreateOrg = z.infer<typeof ZOnboardingCreateOrg>;
|
||||||
|
|
||||||
export const ZOnboardingStep2 = z.object({
|
export const ZOnboardingUpdateMetadata = z.object({
|
||||||
fullname: fullnameValidation,
|
fullname: fullnameValidation,
|
||||||
goal: z.string().min(5),
|
goal: z.string().min(5),
|
||||||
source: z.string().min(5)
|
source: z.string().min(5)
|
||||||
});
|
});
|
||||||
export type TOnboardingStep2 = z.infer<typeof ZOnboardingStep2>;
|
export type TOnboardingUpdateMetadata = z.infer<typeof ZOnboardingUpdateMetadata>;
|
||||||
|
|||||||
Reference in New Issue
Block a user