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:
Rotimi Best
2025-12-05 17:24:16 +01:00
parent eff364baa4
commit 4b52906445
34 changed files with 777 additions and 189 deletions
+3 -28
View File
@@ -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
+543
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
const envSchema = z.object({
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
+1 -1
View File
@@ -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 {
-32
View File
@@ -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);
}
});
+76 -31
View File
@@ -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);
+2 -2
View File
@@ -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 {
+4 -4
View File
@@ -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');
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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();
+3 -3
View File
@@ -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');
@@ -17,7 +17,7 @@
</script>
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
<Button variant="outline">
<Button variant="default">
{#if !$isMobile}
{#if isLMS}
{$t('dashboard.visit_site')}
@@ -25,6 +25,6 @@
{$t('settings.subheadings.view_site')}
{/if}
{/if}
<ArrowUpRightIcon />
<ArrowUpRightIcon class="custom" />
</Button>
</a>
@@ -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}`
}
});
@@ -88,8 +88,8 @@ export class ProfileApi extends BaseApiWithErrors {
return;
}
await this.execute<typeof classroomio.account.user.$put>({
requestFn: () => classroomio.account.user.$put({ json: profileUpdates }),
await this.execute<typeof classroomio.account.profile.$put>({
requestFn: () => classroomio.account.profile.$put({ json: profileUpdates }),
logContext: 'updating profile',
onSuccess: (response) => {
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">
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/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 TextArea from '$lib/components/Form/TextArea.svelte';
import { copyCourseModalInitialState, copyCourseModal } from '$lib/features/course/utils/store';
import { classroomio } from '$lib/utils/services/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
import { copyCourseModal } from '$lib/features/course/utils/store';
import { courseCloneApi } from '$lib/features/course/api';
import { t } from '$lib/utils/functions/translations';
// clone, show spinner and redirect to new course
async function createCourse() {
// Prevent double submission (Option 1: Early Return Guard)
if ($copyCourseModal.isSaving) return;
if ($copyCourseModal.isSaving || courseCloneApi.isLoading) return;
console.log('Create course');
$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
}
});
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;
}
await courseCloneApi.clone($copyCourseModal.id, $copyCourseModal.title, $copyCourseModal.description);
}
// Sync loading state with modal store
$effect(() => {
$copyCourseModal.isSaving = courseCloneApi.isLoading;
});
</script>
<Modal
@@ -76,6 +35,7 @@
className="mb-4"
isRequired={true}
autoComplete={false}
errorMessage={courseCloneApi.errors.title}
/>
<TextArea
@@ -84,8 +44,13 @@
placeholder={$t('courses.copy_course.course_description_placeholder')}
className="mb-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">
<PrimaryButton
className="px-6 py-3"
@@ -32,8 +32,8 @@ export class OnboardingApi extends BaseApiWithErrors {
return false;
}
await this.execute<typeof classroomio.onboarding.step1.$post>({
requestFn: () => classroomio.onboarding.step1.$post({ json: data }),
await this.execute<(typeof classroomio.onboarding)['create-org']['$post']>({
requestFn: () => classroomio.onboarding['create-org'].$post({ json: data }),
logContext: 'submitting organization setup',
onSuccess: (result) => {
const { organizations } = result.data;
@@ -70,8 +70,8 @@ export class OnboardingApi extends BaseApiWithErrors {
return false;
}
await this.execute<typeof classroomio.onboarding.step2.$post>({
requestFn: () => classroomio.onboarding.step2.$post({ json: data }),
await this.execute<(typeof classroomio.onboarding)['update-metadata']['$post']>({
requestFn: () => classroomio.onboarding['update-metadata'].$post({ json: data }),
logContext: 'submitting organization setup',
onSuccess: (result) => {
profile.set(result.data);
@@ -1,5 +1,5 @@
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 { blockedSubdomain } from '@cio/utils/constants';
@@ -11,14 +11,14 @@ export const onboardingValidation = (field: OnboardingField, step: OnboardingSte
return { siteName: 'Sitename already exists.' };
}
const result = ZOnboardingStep1.safeParse(field);
const result = ZOnboardingCreateOrg.safeParse(field);
if (!result.success) {
return mapZodErrorsToTranslations(result.error);
}
}
if (step === ONBOARDING_STEPS.USER_METADATA) {
const result = ZOnboardingStep2.safeParse(field);
const result = ZOnboardingUpdateMetadata.safeParse(field);
if (!result.success) {
return mapZodErrorsToTranslations(result.error);
}
@@ -21,8 +21,8 @@ class DashStatApi extends BaseApi {
* @returns Organization analytics data (enrollments, courses, revenue, students, top courses)
*/
async fetchOrgStats({ orgId, siteName }: { orgId?: string; siteName?: string }) {
await this.execute<typeof classroomio.dash.stats.$post>({
requestFn: () => classroomio.dash.stats.$post({ json: { orgId, siteName } }),
await this.execute<typeof classroomio.dash.stats.$get>({
requestFn: () => classroomio.dash.stats.$get({ query: { orgId, siteName } }),
logContext: 'fetching organization stats',
onSuccess: (response) => {
this.stats = response.data;
@@ -276,7 +276,7 @@ class OrgApi extends BaseApiWithErrors {
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
return this.execute<InviteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team.invite.$post({
classroomio.organization[':orgId']['team']['invite']['$post']({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: { emails, roleId }
@@ -34,7 +34,7 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
// 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 DashStatsSuccess = Extract<InferResponseType<GetDashStatsResponse>, { success: true }>;
@@ -8,7 +8,7 @@ import { classroomio } from './api';
*/
export async function uploadImage(file: File): Promise<string> {
try {
const response = await classroomio.media.image.upload.$post({
const response = await classroomio.media.image.$post({
form: {
file: file
}
+1 -1
View File
@@ -12,7 +12,7 @@ const sendEmail = (sFetch: typeof fetch) => {
}[]
) => {
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) {
console.log('Failed to send emails');
+1 -1
View File
@@ -52,7 +52,7 @@ await send('forgotPassword', {
```typescript
// packages/email/src/emails/forgot-password.ts
import { z } from 'zod';
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
const envSchema = z.object({
SMTP_HOST: z.string().optional(),
+2 -2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
@@ -23,4 +24,3 @@ export const inviteTeacherEmail = defineEmail({
return getDefaultTemplate(content);
}
});
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -160,7 +160,7 @@ validations.{fieldName}.{errorCode}
```typescript
// packages/utils/src/validation/lesson/lesson.ts
import { z } from 'zod';
import * as z from 'zod';
/**
* Lesson Create Validation Schema
@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const ZUpdateProfile = z.object({
fullname: z.string().min(5).optional(),
+12 -3
View File
@@ -1,9 +1,8 @@
import * as z from 'zod';
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
import { z } from 'zod';
export const ZCourseClone = z.object({
id: z.string().min(1),
title: z.string().min(1),
description: z.string().optional(),
slug: z.string().min(1),
@@ -11,6 +10,16 @@ export const ZCourseClone = z.object({
});
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({
theme: z.string().min(1),
studentName: z.string().min(1),
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const ZEmailData = z.object({
from: z.string().optional(),
+2 -1
View File
@@ -1,5 +1,6 @@
import * as z from 'zod';
import { ALLOWED_IMAGE_TYPES } from '../constants';
import { z } from 'zod';
/**
* Schema for image upload presign URL request
@@ -2,7 +2,7 @@ import * as z from 'zod';
const fullnameValidation = z.string().min(5);
export const ZOnboardingStep1 = z.object({
export const ZOnboardingCreateOrg = z.object({
fullname: fullnameValidation,
orgName: z
.string()
@@ -17,11 +17,11 @@ export const ZOnboardingStep1 = z.object({
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,
goal: z.string().min(5),
source: z.string().min(5)
});
export type TOnboardingStep2 = z.infer<typeof ZOnboardingStep2>;
export type TOnboardingUpdateMetadata = z.infer<typeof ZOnboardingUpdateMetadata>;