Files
classroomio/ARCHITECHTURE.md
T
Rotimi BestandAbdulmuizz Hamzat 9fb6c7cbaa feat: migrate course UI from supabase (#615)
* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <[email protected]>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <[email protected]>
2025-12-22 02:36:48 +01:00

2526 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Route Development Guide
## Overview
This guide outlines the standard practices for creating new API routes in ClassroomIO. **All routes should be designed with the assumption they may become public API endpoints**, requiring careful attention to consistency, security, validation, and documentation.
---
## Architecture Layers
Our API follows a four-layer architecture with shared validation:
```txt
┌─────────────────────────────────────┐
│ Frontend (Svelte/SvelteKit) │
│ - UI Components │
│ - API Client (RPC with Hono) │
│ - Client-side Validation │
└──────────────┬──────────────────────┘
│
│ ┌────────────────────────────┐
│ │ Shared Validation Layer │
├──┤ (@cio/utils/validation) │
│ │ - Zod Schemas │
│ │ - Type Definitions │
│ └────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Route Layer (Hono) │
│ - HTTP Request/Response │
│ - Input Validation (Zod) │
│ - Authentication Check │
│ - Calls Services │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Service Layer │
│ - Business Logic │
│ - Transaction Orchestration │
│ - Error Handling │
│ - Calls Queries │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Query Layer (Drizzle ORM) │
│ - Database Operations │
│ - Pure Functions │
│ - Type-Safe Queries │
└─────────────────────────────────────┘
```
**Key Principles**:
- **Shared Validation**: Zod schemas in `@cio/utils/validation` used by both API and frontend
- **Routes**: Handle HTTP concerns only (validation, auth, serialization)
- **Services**: Contain all business logic and orchestrate queries
- **Queries**: Pure database operations with no business logic
---
## Type Generation
**Location**: `packages/db/src/types.ts` (auto-generated)
### How Types Are Generated
Types are automatically generated from your Drizzle schema using Drizzle's built-in type inference:
```typescript
// packages/db/src/types.ts (auto-generated - DO NOT EDIT)
import * as schema from './schema';
// For each table, two types are generated:
export type TUser = typeof schema.user.$inferSelect; // For reading
export type TNewUser = typeof schema.user.$inferInsert; // For creating
export type TOrganization = typeof schema.organization.$inferSelect;
export type TNewOrganization = typeof schema.organization.$inferInsert;
```
**To regenerate types after schema changes:**
```bash
cd packages/db
pnpm generate-types
```
This script:
1. Reads `src/schema.ts` to find all tables and enums
2. Generates type definitions using Drizzle's `$inferSelect` and `$inferInsert`
3. Outputs to `src/types.ts`
4. Formats the file with Prettier
**When to regenerate:**
- ✏️ After adding/removing tables in `schema.ts`
- ✏️ After adding/removing columns in existing tables
- ✏️ After changing column types or constraints
- ℹ️ **Not needed** for data changes (migrations) - only for schema definition changes
---
## Step 1: Create Reusable Queries
**Location**: `packages/db/src/queries/{domain}/{entity}.ts`
### Principles
1. **Pure Functions**: Queries should be pure database operations with no business logic
2. **Single Responsibility**: Each query does one thing well
3. **Type Safety**: Leverage Drizzle's type inference for automatic types
4. **Reusability**: Queries can be used across multiple routes and services
### Example: Organization Queries
```typescript
// packages/db/src/queries/organization/organization.ts
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
import { eq } from 'drizzle-orm';
import type { TNewOrganization, TOrganization } from '@db/types';
/**
* Creates a new organization
* @param data Organization creation data
* @returns Created organization record
*/
export const createOrganization = async (data: TNewOrganization) => {
const [organization] = await db.insert(schema.organization).values(data).returning();
return organization;
};
/**
* Updates an organization
* @param id Organization ID
* @param data Partial organization data
* @returns Updated organization record
*/
export const updateOrganization = async (id: string, data: TOrganization) => {
const [organization] = await db
.update(schema.organization)
.set(data)
.where(eq(schema.organization.id, id))
.returning();
return organization;
};
```
**Type System:**
- Types are auto-generated from Drizzle schema using built-in inference
- `TNewOrganization` = `typeof schema.organization.$inferInsert` (for creates)
- `TOrganization` = `typeof schema.organization.$inferSelect` (for reads/updates)
- Input validation happens at the API route layer (not in queries)
- Queries are pure database operations with compile-time type safety
### Query Best Practices
#### ✅ DO
- **Use exported types from `@cio/db/types`** (e.g., `TNewOrganization`, `TOrganization`)
- **Use `TNew{Table}` types** for create operations (e.g., `TNewOrganization`)
- **Use `T{Table}` types** for read operations (e.g., `TOrganization`)
- **Use `Partial<T{Table}>` types** for update operations (e.g., `Partial<TOrganization>`)
- Use descriptive names (`getOrganizationByProfileId`, not `getOrg`)
- Add JSDoc comments for all exported functions
- Return inferred types from Drizzle
- Use `.returning()` for insert/update operations to get the created/updated record
- Use `.limit(1)` for single-record queries
- Handle null/undefined cases explicitly
- Rely on Drizzle's compile-time type safety
#### ❌ DON'T
- Define ad-hoc types when `@cio/db/types` exports them
- Add runtime validation in queries (validation belongs in API routes)
- Include business logic in queries (authorization, side effects)
- Make HTTP calls or external API calls from queries
- Access environment variables directly
- Handle transactions within individual queries (do this at service layer)
- Throw custom `AppError` (let database errors bubble up)
- Mix concerns (e.g., sending emails from a query function)
### Export Queries
```typescript
// packages/db/src/queries/organization/index.ts
export * from './organization';
export * from './types';
// packages/db/src/queries/index.ts
export * from './organization';
export * from './auth';
export * from './course';
```
---
## Step 2: Create Services (Business Logic)
**Location**: `apps/api/src/services/{domain}.ts`
### Principles
1. **Business Logic Only**: Services contain business rules, validations, and orchestration
2. **Transaction Management**: Services handle database transactions
3. **Error Handling**: Services throw meaningful errors that routes can handle
4. **Testable**: Services should be easy to unit test
5. **No HTTP Concerns**: Services don't know about requests/responses
### Example: Organization Service
```typescript
// apps/api/src/services/onboarding.ts
import { db } from '@cio/db/drizzle';
import {
createOrganization,
createOrganizationMember,
checkSiteNameExists,
getOrganizationByProfileId
} from '@cio/db/queries/organization';
import { updateProfile } from '@cio/db/queries/auth';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TProfile } from '@cio/db/types';
/**
* Creates a new organization with the user as owner
*/
export async function createOrganizationWithOwner(
profileId: string,
input: { orgName: string; siteName: string; fullname?: string }
) {
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists(input.siteName);
if (exists) {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
// Business Logic: Create org and member in a transaction
try {
const result = await db.transaction(async (tx) => {
const organization = await createOrganization({
name: input.orgName,
siteName: input.siteName
});
const member = await createOrganizationMember({
organizationId: organization.id,
profileId,
roleId: 1, // Owner role
verified: true
});
return { organization, member };
});
// Fetch updated organizations list
const organizations = await getOrganizationByProfileId(profileId);
return {
organization: result.organization,
member: result.member,
organizations
};
} catch (error) {
// Handle database constraint violations
if (error.code === '23505') {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
```
### Error Handling in Services
Use `AppError` for consistent error handling across services:
```typescript
// apps/api/src/utils/errors.ts
import type { ContentfulStatusCode } from 'hono/utils/http-status';
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: ContentfulStatusCode = 500,
public field?: string // Optional: for field-specific validation errors
) {
super(message);
this.name = 'AppError';
}
}
export const ErrorCodes = {
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND'
// ... add more as needed
} as const;
```
**Why?** One reusable class scales infinitely vs creating custom error classes for every scenario.
### Error Handling in Routes
Use the `handleError` utility for consistent error responses across all routes:
```typescript
// apps/api/src/utils/errors.ts
export const handleError = (
c: Context,
error: unknown,
fallbackMessage: string = 'An unexpected error occurred',
fallbackCode: string = ErrorCodes.INTERNAL_ERROR
) => {
console.error('Error in route:', error);
if (error instanceof AppError) {
return c.json(
{
success: false,
error: error.message,
code: error.code,
field: error.field
},
error.statusCode
);
}
return c.json(
{
success: false,
error: fallbackMessage,
code: fallbackCode
},
500
);
};
```
**Usage in routes:**
```typescript
// Simple usage with default message
try {
const data = await someService();
return c.json({ success: true, data });
} catch (error) {
return handleError(c, error);
}
// With custom fallback message
try {
const data = await fetchAccount(userId);
return c.json({ success: true, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch account data');
}
// With custom fallback message AND error code
try {
await deliverEmail(data);
return c.json({ success: true });
} catch (error) {
return handleError(c, error, 'Failed to send email', 'EMAIL_SEND_FAILED');
}
```
**Benefits:**
- ✅ **Consistency**: All routes return the same error format
- ✅ **DRY**: No repetitive error handling code
- ✅ **Type-safe**: Proper TypeScript types for responses
- ✅ **Flexible**: Custom fallback messages per route
- ✅ **Field-aware**: Supports field-specific validation errors
### Service Best Practices
#### ✅ DO
- **Use descriptive function names** that explain the business operation
- **Throw `AppError`** with appropriate error codes and status codes
- **Handle transactions** within services
- **Validate business rules** (e.g., checking if sitename exists)
- **Return structured data** that routes can easily serialize
- **Add JSDoc comments** explaining the business logic
- **Keep services focused** on a specific domain
#### ❌ DON'T
- Return HTTP responses (status codes, headers)
- Access request/response objects directly
- Handle HTTP-specific concerns (CORS, cookies, etc.)
- Mix multiple domains in one service (keep focused)
- Catch errors just to log them (let them bubble up)
- Create custom error classes for every error type (use `AppError`)
### Export Services
```typescript
// apps/api/src/services/onboarding.ts
export * from './onboarding';
// apps/api/src/services/index.ts
export * from './onboarding';
export * from './account';
export * from './course';
```
---
## Step 3: Create API Route
**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:
#### 1. **Consistent Response Format**
All API responses should follow a consistent structure:
```typescript
// Success Response
{
"success": true,
"data": { ... }, // The actual response data
"message": "Optional" // Optional human-readable message
}
// Error Response
{
"success": false,
"error": "Human-readable error message",
"code": "ERROR_CODE", // Optional: machine-readable error code
"field": "fieldName" // Optional: for validation errors, indicates which field failed
}
// List Response (with pagination)
{
"success": true,
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
```
#### 2. **HTTP Status Codes**
Use semantically correct HTTP status codes:
| Code | Meaning | Usage |
| --------------------------- | ------------ | -------------------------------------------------------- |
| `200 OK` | Success | Successful GET, PUT, PATCH requests |
| `201 Created` | Created | Successful POST creating a new resource |
| `204 No Content` | Success | Successful DELETE or update with no response body |
| `400 Bad Request` | Client Error | Malformed request, validation errors |
| `401 Unauthorized` | Auth Error | Missing or invalid authentication token |
| `403 Forbidden` | Auth Error | Authenticated but not authorized for this action |
| `404 Not Found` | Client Error | Resource doesn't exist |
| `409 Conflict` | Client Error | Duplicate resource (e.g., email/sitename already exists) |
| `422 Unprocessable Entity` | Client Error | Valid syntax but semantic validation failed |
| `429 Too Many Requests` | Client Error | Rate limit exceeded |
| `500 Internal Server Error` | Server Error | Unexpected server error |
| `503 Service Unavailable` | Server Error | Temporary service outage |
#### 3. **Input Validation with Zod**
**Always validate all input** using Zod schemas. Define ALL validation schemas in `packages/utils/src/validation/` organized by entity.
**Why in utils package?**
- ✅ Shared between API (server-side validation) and Dashboard (client-side validation)
- ✅ Single source of truth - no schema duplication
- ✅ Type-safe across the entire monorepo
- ✅ Convention-based translations (clean schemas without hardcoded messages)
**File Structure**:
```txt
packages/utils/src/validation/
├── course/
│ ├── course.ts ← All course validation schemas
│ └── index.ts ← Re-exports
├── mail/
│ ├── mail.ts ← Mail validation schemas
│ └── index.ts ← Re-exports
├── constants.ts ← Shared constants
├── index.ts ← Main exports
└── README.md ← Documentation
```
**File Naming Convention**:
All file names should use **lowercase with hyphens** (kebab-case):
- ✅ `setup-progress.svelte` (component files)
- ✅ `org.svelte.ts` (API service files)
- ✅ `course.ts` (validation/query files)
- ✅ `user-profile.svelte` (multi-word component files)
- ❌ `SetupProgress.svelte` (PascalCase - reserved for component names in imports)
- ❌ `Org.svelte.ts` (PascalCase)
- ❌ `userProfile.svelte` (camelCase)
- ❌ `user_profile.svelte` (snake_case)
**Note**: Component names in imports can still use PascalCase (e.g., `import SetupProgress from './setup-progress.svelte'`), but the actual file names must be lowercase with hyphens.
**Example: Course Validations.**
Schemas should be **clean** - only validation rules, no error messages:
```typescript
// packages/utils/src/validation/course/course.ts
import * as z from 'zod';
import { ALLOWED_CONTENT_TYPES } from '../constants';
export const ZCourseClone = z.object({
id: z.string().min(1), // No hardcoded message
title: z.string().min(1), // No hardcoded message
description: z.string().optional(),
slug: z.string().min(1),
organizationId: z.string().min(1)
});
export type TCourseClone = z.infer<typeof ZCourseClone>;
// packages/utils/src/validation/course/index.ts
export * from './course';
```
**Using in API Routes**:
```typescript
// apps/api/src/routes/course/clone.ts
import { ZCourseClone } from '@cio/utils/validation/course';
import { zValidator } from '@hono/zod-validator';
export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
const data = c.req.valid('json'); // Fully typed!
// ... route handler
});
```
**Using in Frontend Forms**:
```typescript
// apps/dashboard/src/components/CourseForm.svelte
import { ZCourseClone } from '@cio/utils/validation/course';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
const result = ZCourseClone.safeParse(formData);
if (!result.success) {
// Maps Zod errors to translated messages using convention
// Pass 'course' as entity name for entity-specific translations
errors = mapZodErrorsToTranslations(result.error, 'course');
// errors.title = "Course title is required" (translated)
}
```
**Convention-Based Translations**:
Frontend maps validation errors to translations using a **smart fallback system**:
1. **Entity-specific**: `validations.{entityName}.{fieldName}.{errorCode}` (most specific)
2. **Generic field**: `validations.{fieldName}.{errorCode}` (fallback)
3. **Generic type**: `validations.generic.{errorType}` (last resort)
This allows different messages for shared field names (e.g., "Course title is required" vs "Lesson title is required"):
```json
// apps/dashboard/src/lib/utils/translations/en.json
{
"validations": {
// Entity-specific translations (preferred)
"course": {
"title": { "too_small": "Course title is required" },
"slug": { "too_small": "Course slug is required" }
},
"lesson": {
"title": { "too_small": "Lesson title is required" },
"duration": { "too_small": "Lesson duration must be at least 1 minute" }
},
// Generic field translations (fallback for fields used across entities)
"title": { "too_small": "Title is required" },
"email": { "invalid_email": "Please enter a valid email address" },
// Generic error type translations (last resort)
"generic": {
"required": "This field is required",
"invalid_email": "Invalid email address",
"too_small_string": "Must be at least {min} characters"
}
}
}
```
**Entity Name Usage**:
```typescript
// With entity name (entity-specific messages)
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
mapZodErrorsToTranslations(error, 'lesson'); // "Lesson title is required"
// Without entity name (generic messages)
mapZodErrorsToTranslations(error); // "Title is required"
```
**Key Principles**:
- **Keep schemas clean**: No hardcoded error messages (translations handled in frontend)
- **Single source of truth**: One schema used by both API and frontend
- **Entity-specific translations**: Use entity name parameter for context-specific error messages
- **Smart fallbacks**: Entity-specific → Generic field → Generic type → Hardcoded
- **Follow naming convention**: Schemas with `Z` prefix, types with `T` prefix
- **Organize by entity**: Each entity gets its own folder
---
#### 4. **Translation Strategy**
**Overview**: Our validation system uses a **convention-based translation approach** with smart fallbacks to provide flexible, maintainable error messages.
##### Translation Lookup Order
When a validation error occurs, the system tries translation keys in this order:
1. **Entity-specific** (most specific): `validations.course.title.too_small`
2. **Generic field** (fallback): `validations.title.too_small`
3. **Generic type** (last resort): `validations.generic.too_small_string`
4. **Hardcoded fallback**: "Must be at least 1 character"
##### When to Use Entity-Specific Translations
Use entity names when:
✅ **Shared field names** across entities
- Example: Both Course and Lesson have `title`, but need different context
- Solution: `mapZodErrorsToTranslations(error, 'course')` vs `mapZodErrorsToTranslations(error, 'lesson')`
✅ **Entity-specific context** matters
- Example: "Course description helps students understand..." vs "Lesson description explains..."
- Better UX with contextual messaging
✅ **Business domain clarity**
- Makes error messages more professional and domain-specific
Skip entity names when:
❌ **Universal fields** that mean the same everywhere
- Example: `email`, `password`, `firstName` - same validation message across all entities
❌ **Unique field names** that only exist in one entity
- Example: `certificateTemplate` only in Course entity
❌ **Generic utility forms** where entity context isn't relevant
##### Translation File Organization
```json
{
"validations": {
// 1. Entity-specific (for shared field names)
"course": {
"title": {
"too_small": "Course title is required",
"too_big": "Course title must be less than 100 characters"
},
"description": {
"too_small": "Please add a course description to help students"
}
},
"lesson": {
"title": {
"too_small": "Lesson title is required",
"too_big": "Lesson title must be less than 100 characters"
},
"duration": {
"too_small": "Lesson must be at least 1 minute long"
}
},
// 2. Generic field-level (fallback for common fields)
"title": {
"too_small": "Title is required"
},
"email": {
"invalid_email": "Please enter a valid email address"
},
"password": {
"too_small": "Password must be at least 8 characters"
},
// 3. Generic error types (last resort fallback)
"generic": {
"required": "This field is required",
"invalid_email": "Invalid email address",
"invalid_url": "Invalid URL format",
"too_small_string": "Must be at least {min} characters",
"too_big_string": "Must be at most {max} characters",
"invalid_type": "Invalid value type",
"invalid_enum": "Invalid selection"
}
}
}
```
##### Implementation Examples
**Example 1: Entity-specific (Course form).**
```typescript
import { ZCourseCreate } from '@cio/utils/validation/course';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
const result = ZCourseCreate.safeParse(formData);
if (!result.success) {
errors = mapZodErrorsToTranslations(result.error, 'course');
// Result: "Course title is required" (entity-specific)
}
```
**Example 2: Generic (User profile form).**
```typescript
import { ZUserProfile } from '@cio/utils/validation/user';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
const result = ZUserProfile.safeParse(formData);
if (!result.success) {
errors = mapZodErrorsToTranslations(result.error);
// Result: "Please enter a valid email address" (generic)
}
```
**Example 3: Mixed fields.**
```typescript
// Course has both shared fields (title) and unique fields (certificateTemplate)
const result = ZCourseUpdate.safeParse(formData);
if (!result.success) {
errors = mapZodErrorsToTranslations(result.error, 'course');
// errors.title = "Course title is required" (entity-specific)
// errors.email = "Please enter a valid email address" (generic fallback)
}
```
##### Benefits of This Approach
✅ **Flexible**: Use entity-specific messages only where needed
✅ **Scalable**: Add new entities without updating all translations
✅ **Maintainable**: Clear fallback hierarchy reduces duplication
✅ **Developer-friendly**: Works with or without entity name parameter
✅ **Multi-language**: Same system works across all locales
✅ **Professional UX**: Context-specific messages improve user experience
---
#### 5. **Authentication & Authorization**
```typescript
import { authMiddleware } from '@api/middlewares/auth';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
// Protected route - requires user authentication (session cookies)
.post('/create', authMiddleware, async (c) => {
const user = c.get('user'); // Always available after authMiddleware
const session = c.get('session');
// ...
});
// Protected route - requires API key (server-to-server)
.post('/webhook', apiKeyMiddleware, async (c) => {
// API key validated, proceed
// ...
});
// Protected route - accepts either user session OR API key
.post('/plan', authOrApiKeyMiddleware, async (c) => {
// Can be called by:
// 1. Authenticated users (via session cookies)
// 2. Server-to-server (via API key: Authorization: Bearer <key>)
// ...
});
// Public endpoint - no authentication required
.get('/public-courses', async (c) => {
// Publicly accessible data
});
// Optional authentication - checks if user is logged in but doesn't require it
.get('/courses', async (c) => {
const user = c.get('user'); // May be null
if (user) {
// Show personalized content
} else {
// Show public content
}
});
```
**API Key Authentication:**
- Add `API_KEY` to `apps/api/src/config/env.ts` environment schema
- Use `Authorization: Bearer <api-key>` header format (standard across popular APIs)
- Use `apiKeyMiddleware` for API-key-only endpoints
- Use `authOrApiKeyMiddleware` for endpoints that accept both user sessions and API keys
### Complete Route Example
Routes should be thin - they handle HTTP concerns and delegate to services:
```typescript
// apps/api/src/routes/organization/organization.ts
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { zValidator } from '@hono/zod-validator';
import { createOrganizationWithOwner } from '@api/services/organization';
import { handleError } from '@api/utils/errors';
import { ZCreateOrg } from '@cio/utils/validation/organization';
export const organizationRouter = new Hono().post(
'/create',
authMiddleware,
zValidator('json', ZCreateOrg),
async (c) => {
try {
const user = c.get('user');
const body = c.req.valid('json');
const result = await createOrganizationWithOwner(user.id, body);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
}
);
```
**Key Points:**
- Route is ~20 lines - clean and focused
- Single line error handling using `handleError` utility
- Automatically handles `AppError` instances with correct status codes
- No error code → status code mapping needed
- Easy to test service independently
- Route only handles HTTP concerns (validation, auth, response formatting)
### Route Best Practices
#### ✅ DO
- **Validate all inputs** with Zod schemas
- **Call services** for all business logic
- **Use `handleError` utility** in all catch blocks for consistent error responses
- **Provide custom fallback messages** to `handleError` for better error context
- **Include field names** in validation errors (via `AppError` field parameter)
- **Use consistent response format** across all routes
- **Document routes** with JSDoc comments
- **Keep routes thin** - only HTTP concerns (validation, auth, response formatting)
#### ❌ DON'T
- Put business logic in routes (use services)
- Return raw database errors to clients
- Write repetitive error handling code (use `handleError` utility)
- Use `200 OK` for all responses regardless of outcome
- Leave sensitive information in error messages
- Perform heavy computation in route handlers (use background jobs or services)
- Skip input validation "because the frontend validates"
- Use generic error messages like "Something went wrong" (provide context)
- Expose internal implementation details in responses
- Call queries directly from routes (use services)
---
## Creating a New Route: Complete Walkthrough
This section provides a complete end-to-end example of creating a new API route, based on the user profile update implementation.
### Example: User Profile Update Route
We'll create a `PUT /account/user` route that updates a user's profile information.
---
### Step 1: Create Validation Schema
**Location**: `packages/utils/src/validation/{domain}/{entity}.ts`
```typescript
import * as z from 'zod';
export const ZUpdateProfile = z.object({
fullname: z.string().min(5).optional(),
username: z.string().min(1).optional(),
locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']).optional()
});
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
```
**Export**: Add to `packages/utils/src/validation/{domain}/index.ts` and `packages/utils/src/validation/index.ts`
---
### Step 2: Create Service Function
**Location**: `apps/api/src/services/{domain}.ts`
```typescript
import { AppError, ErrorCodes } from '@api/utils/errors';
import { updateProfile } from '@cio/db/queries/auth';
export async function updateUser(userId: string, data: Partial<TProfile>) {
try {
const updatedProfile = await updateProfile(userId, data);
if (!updatedProfile) {
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
return updatedProfile;
} catch (error) {
if (error instanceof AppError) throw error;
// Handle constraint violations
if (error instanceof Error && error.message.includes('profile_username_key')) {
throw new AppError('Username already exists', ErrorCodes.VALIDATION_ERROR, 400, 'username');
}
throw new AppError('Failed to update profile', ErrorCodes.PROFILE_UPDATE_FAILED, 500);
}
}
```
---
### Step 3: Create Route
**Location**: `apps/api/src/routes/{domain}/{entity}.ts`
```typescript
import { updateUser } from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono().put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
try {
const user = c.get('user');
const validatedData = c.req.valid('json');
const updatedProfile = await updateUser(user.id, validatedData);
return c.json({ success: true, profile: updatedProfile }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update profile');
}
});
```
---
### Step 4: Export and Register Route
```typescript
// apps/api/src/routes/account/index.ts
export * from './account';
// apps/api/src/app.ts
import { accountRouter } from '@api/routes/account';
export const app = new Hono().route('/account', accountRouter); // Route available at PUT /account/user
```
---
### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
### Step 6: Use in Frontend
**Location**: `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`
```typescript
import { ZProfileUpdateForm } from '@cio/utils/validation/account';
import { classroomio } from '$lib/utils/services/api';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
export class ProfileApi {
isLoading = $state(false);
errors = $state<Record<string, string>>({});
async submit(fields: TProfileUpdateForm) {
// Client-side validation
const result = ZProfileUpdateForm.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return;
}
try {
this.isLoading = true;
// Call API using RPC client (matches route path)
const response = await classroomio.account.user.$put({
json: result.data
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
// Update local state, show success message, etc.
} catch (error) {
this.errors.general = error.message;
} finally {
this.isLoading = false;
}
}
}
```
---
### Example: Creating an Organization (POST Route)
We'll create a `POST /organization` route that creates a new organization with the current user as owner.
---
### Step 1: Create Validation Schema
**Location**: `packages/utils/src/validation/organization/organization.ts`
```typescript
import * as z from 'zod';
export const ZCreateOrganization = z.object({
name: z
.string()
.min(5)
.refine((val) => !/^[-]|[-]$/.test(val), {
message: 'Organization name cannot start or end with a hyphen'
}),
siteName: z
.string()
.min(5)
.refine((val) => !/^[-]|[-]$/.test(val), {
message: 'Site name cannot start or end with a hyphen'
})
});
export type TCreateOrganization = z.infer<typeof ZCreateOrganization>;
```
**Export**: Add to `packages/utils/src/validation/organization/index.ts` and `packages/utils/src/validation/index.ts`
---
### Step 2: Create or Reuse Service Function
**Location**: `apps/api/src/services/organization.ts`
**Option A: Reuse Existing Service** (if function exists in another service)
```typescript
import { createOrganizationWithOwner } from '@api/services/onboarding';
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
// Reuse existing function from onboarding service
return await createOrganizationWithOwner(profileId, {
orgName: data.name,
siteName: data.siteName
});
}
```
**Option B: Create New Service** (if you need different logic)
```typescript
import { AppError, ErrorCodes } from '@api/utils/errors';
import { db } from '@cio/db/drizzle';
import {
checkSiteNameExists,
createOrganization,
createOrganizationMember,
getOrganizationByProfileId
} from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists(data.siteName);
if (exists) {
throw new AppError(
`Site name '${data.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409,
'siteName'
);
}
// Business Logic: Create org and member in a transaction
try {
const result = await db.transaction(async (tx) => {
const organization = await createOrganization({
name: data.name,
siteName: data.siteName
});
const member = await createOrganizationMember({
organizationId: organization.id,
profileId,
roleId: ROLE.ADMIN,
verified: true
});
return { organization, member };
});
// Fetch updated organizations list
const organizations = await getOrganizationByProfileId(profileId);
return {
organization: result.organization,
member: result.member,
organizations
};
} catch (error) {
// Handle database constraint violations
if (error.code === '23505') {
throw new AppError(
`Site name '${data.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409,
'siteName'
);
}
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
```
---
### Step 3: Create Route
**Location**: `apps/api/src/routes/organization/organization.ts`
```typescript
import { createOrg } from '@api/services/organization';
import { Hono } from '@api/utils/hono';
import { ZCreateOrganization } from '@cio/utils/validation/organization';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
.post('/', authMiddleware, zValidator('json', ZCreateOrganization), async (c) => {
try {
const user = c.get('user');
const data = c.req.valid('json');
const result = await createOrg(user.id, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
});
```
**Note**: Use `201 Created` status code for successful POST requests that create resources.
---
### Step 4: Export and Register Route
```typescript
// apps/api/src/routes/organization/index.ts
export * from './organization';
// apps/api/src/app.ts
import { organizationRouter } from '@api/routes/organization';
export const app = new Hono()
.route('/organization', organizationRouter); // Route available at POST /organization
```
---
### Step 5: Use in Frontend
**Location**: `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
```typescript
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { ZCreateOrganization } from '@cio/utils/validation/organization';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { orgs, currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
class OrgApi extends BaseApiWithErrors {
async create(fields: { name: string; siteName: string }) {
// Client-side validation
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
if (!response.data) return;
const { organization, organizations } = response.data;
// Update stores
orgs.set(organizations);
currentOrg.set(organization);
snackbar.success('Organization created successfully');
this.success = true;
this.errors = {};
},
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;
}
}
});
}
}
export const orgApi = new OrgApi();
```
**Key Points:**
- Use `this.execute()` from `BaseApiWithErrors` for automatic loading/error state
- Field-specific errors are automatically mapped to `this.errors[field]`
- Update stores in `onSuccess` callback
- Handle both validation and API errors
---
### Reusing Existing Service Functions
Sometimes a service function exists in a different context (e.g., `createOrganizationWithOwner` in onboarding service).
**When to Reuse:**
- ✅ The function does exactly what you need
- ✅ It's in the same domain (organization operations)
- ✅ You can call it from your new route without modification
**When to Create New:**
- ❌ The existing function has domain-specific logic you don't want (e.g., onboarding-specific)
- ❌ You need different parameters or return values
- ❌ The function is in a different domain and mixing concerns would be inappropriate
**Example: Reusing a Service**
```typescript
// apps/api/src/services/organization.ts
import { createOrganizationWithOwner } from '@api/services/onboarding';
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
// Reuse existing function, just adapt the parameters
return await createOrganizationWithOwner(profileId, {
orgName: data.name,
siteName: data.siteName
});
}
```
---
### Quick Reference Checklist
When creating a new route, follow this checklist:
1. **Validation Schema** (`packages/utils/src/validation/`)
- [ ] Create Zod schema with validation rules
- [ ] Export schema and types
- [ ] Add to validation index
2. **Service** (`apps/api/src/services/`)
- [ ] Create service function with business logic
- [ ] Use `AppError` for structured errors
- [ ] Handle database constraint violations
- [ ] Add JSDoc comments
3. **Route** (`apps/api/src/routes/`)
- [ ] Create route handler
- [ ] Use `zValidator` for input validation
- [ ] Apply `authMiddleware` if needed
- [ ] Use `handleError` for error handling
- [ ] Return consistent response format
4. **Registration**
- [ ] Export router in index file
- [ ] Register route in `app.ts`
5. **Frontend** (`apps/dashboard/src/lib/features/`)
- [ ] Create API abstraction class (`.svelte.ts`)
- [ ] Use shared validation schema
- [ ] Use RPC client for API calls
- [ ] Map errors to translations
- [ ] Update local state after success
---
## Migrating Existing Supabase Calls to API Routes
When you have existing components using direct Supabase calls, follow this migration pattern to move them to the API architecture.
### Migration Workflow
#### Step 1: Identify the Operation
Analyze the existing Supabase call:
- **What database operation?** (CREATE, UPDATE, DELETE, SELECT)
- **What tables are involved?**
- **Are there related operations?** (e.g., create org + create member should be atomic)
- **What validation is currently done?** (client-side only or also server-side?)
**Example Analysis:**
```typescript
// Current code
const { data: org, error } = await supabase
.from('organization')
.insert({ name: orgName, siteName: siteName })
.select();
const { data, error } = await supabase
.from('organizationmember')
.insert({ organization_id: org[0].id, profile_id: $profile.id, role_id: 1 });
// Analysis:
// - Operation: CREATE (organization + member)
// - Tables: organization, organizationmember
// - Related: Yes, should be atomic (transaction)
// - Validation: Client-side only (needs server-side too)
```
#### Step 2: Check for Existing Services
Search for existing service functions that might handle this:
- Check `apps/api/src/services/` for similar operations
- Look in related domains (e.g., onboarding might have org creation)
- Check if the operation exists but in a different context
**Decision:**
- **Reuse existing service** if it does what you need
- **Create new service** if you need different logic or parameters
#### Step 3: Create/Update API Route
If the route doesn't exist:
- Follow the "Creating a New Route" workflow above
- Use appropriate HTTP method: `POST` (create), `PUT` (update), `DELETE` (delete)
If the route exists:
- Add new endpoint to existing router
- 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
Add method to existing API class (e.g., `orgApi.create()`):
```typescript
// apps/dashboard/src/lib/features/org/api/org.svelte.ts
class OrgApi extends BaseApiWithErrors {
async create(fields: { name: string; siteName: string }) {
// Client-side validation
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
// Update stores, navigate, show success
if (response.data) {
orgs.set(response.data.organizations);
currentOrg.set(response.data.organization);
}
snackbar.success('Organization created');
this.success = true;
},
onError: (result) => {
// Error handling (automatic for validation errors)
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
}
```
**Key Points:**
- Always use `this.execute()` from `BaseApiWithErrors`
- Client-side validation before API call
- Update stores in `onSuccess` callback
- Errors automatically handled by `BaseApiWithErrors`
#### Step 5: Update Component
Replace Supabase calls with API class method:
**Before (Direct Supabase):**
```typescript
<script lang="ts">
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
async function createNewOrg() {
// Create organization
const { data: org, error } = await supabase
.from('organization')
.insert({ name: orgName, siteName })
.select();
if (error) {
errors.siteName = error.message;
return;
}
// Create member (separate call - not atomic!)
const { data, error: memberError } = await supabase
.from('organizationmember')
.insert({
organization_id: org[0].id,
profile_id: $profile.id,
role_id: 1
})
.select();
if (memberError) {
// Manual cleanup required
await supabase.from('organization').delete().match({ siteName });
errors.siteName = 'Failed to create member';
return;
}
// Manual store updates
await getOrganizations($profile.id);
goto(`/org/${siteName}`);
}
</script>
```
**After (API Route):**
```typescript
<script lang="ts">
import { orgApi } from '$features/org/api/org.svelte';
import { goto } from '$app/navigation';
async function createNewOrg() {
// Client-side validation (optional, for instant feedback)
errors = createOrgValidation({ orgName, siteName }) as Error;
if (Object.values(errors).length) {
return;
}
// Call API
await orgApi.create({ name: orgName, siteName });
if (orgApi.success) {
// Success - stores already updated by orgApi
goto(`/org/${siteName}`);
$newOrgModal.open = false;
resetForm();
} else {
// Errors automatically mapped to orgApi.errors
if (orgApi.errors.siteName) {
errors.siteName = orgApi.errors.siteName;
} else if (orgApi.errors.general) {
errors.general = orgApi.errors.general;
}
}
}
</script>
```
**Key Differences:**
- ✅ **No manual transaction cleanup** - Handled by service layer
- ✅ **No separate member creation** - Handled atomically by service
- ✅ **Automatic store updates** - Handled by API class `onSuccess`
- ✅ **Consistent error structure** - Field-specific errors automatically mapped
- ✅ **Type-safe** - Full TypeScript support end-to-end
- ✅ **Server-side validation** - Always validated even if client validation passes
### Mapping API Errors to Form Fields
The `BaseApiWithErrors` class automatically maps field-specific errors from the API response:
**API Response Structure:**
```typescript
// Field-specific error
{
success: false,
error: "Sitename already exists",
code: "SITENAME_EXISTS",
field: "siteName" // ← This maps to this.errors.siteName
}
// General error
{
success: false,
error: "Failed to create organization",
code: "ORG_CREATE_FAILED"
// No field property → maps to this.errors.general
}
```
**In Component:**
```typescript
await orgApi.create({ name: orgName, siteName });
if (!orgApi.success) {
// Field-specific error automatically in orgApi.errors.siteName
if (orgApi.errors.siteName) {
errors.siteName = orgApi.errors.siteName;
}
// General error automatically in orgApi.errors.general
if (orgApi.errors.general) {
errors.general = orgApi.errors.general;
}
}
```
**How It Works:**
1. API returns error with `field` property
2. `BaseApiWithErrors.execute()` automatically sets `this.errors[field] = error`
3. Component can access `orgApi.errors.fieldName` directly
4. No manual error mapping needed
### Migration Checklist
When migrating a component from Supabase to API:
- [ ] **Identify operations**: List all Supabase calls in component
- [ ] **Check existing routes**: Search `apps/api/src/routes/` for existing API routes
- [ ] **Check existing services**: Search `apps/api/src/services/` for reusable functions
- [ ] **Create missing pieces**: Follow "Creating a New Route" if route doesn't exist
- [ ] **Create API method**: Add method to appropriate API class using `execute()` pattern
- [ ] **Update component**: Replace Supabase calls with API method
- [ ] **Remove cleanup code**: Delete manual rollback/cleanup (handled by service transactions)
- [ ] **Remove separate calls**: Combine related operations (handled atomically by service)
- [ ] **Update error handling**: Use `orgApi.errors.fieldName` instead of Supabase error structure
- [ ] **Update store sync**: Remove manual store updates (handled by API class `onSuccess`)
- [ ] **Remove Supabase imports**: Delete unused `supabase` imports
- [ ] **Test**: Verify success and error cases work correctly
- [ ] **Test transactions**: Verify atomic operations (e.g., org + member creation)
---
## Step 4: Export and Register Route
### Export Router
```typescript
// apps/api/src/routes/onboarding/index.ts
export * from './onboarding';
```
### Register in Main App
```typescript
// apps/api/src/app.ts
import { onboardingRouter } from '@api/routes/onboarding';
export const app = new Hono()
// ... middleware ...
// Routes
.route('/account', accountRouter)
.route('/onboarding', onboardingRouter) // Add new route
.route('/course', courseRouter)
.route('/mail', mailRouter);
// ... error handling ...
```
---
### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
## Step 6: Update Frontend
**Location**: `apps/dashboard/src/lib/utils/services/api.ts` (or similar)
### Setup Hono RPC Client
The frontend uses Hono's RPC client for type-safe API calls:
```typescript
// apps/dashboard/src/lib/utils/services/api.ts
import { hc } from 'hono/client';
import type { app } from '@api/app';
export const classroomio = hc<typeof app>(import.meta.env.VITE_API_URL || 'http://localhost:3000', {
credentials: 'include', // Important: sends cookies for authentication
headers: {
'Content-Type': 'application/json'
}
});
```
### Using API in Components
Replace Supabase direct database calls with API calls:
```typescript
// Before: Direct Supabase call
const { data, error } = await supabase
.from('organization')
.insert({
name: fields.orgName,
siteName: fields.siteName
})
.select();
// After: Type-safe API call
try {
const response = await classroomio.onboarding.step1.$post({
json: {
fullname: fields.fullname,
orgName: fields.orgName,
siteName: fields.siteName
}
});
const data = await response.json();
if (!data.success) {
// Handle error
errors.siteName = data.error;
return;
}
// Success - use data.data.organization, data.data.member, etc.
const { organization, member, organizations } = data.data;
} catch (error) {
console.error('API call failed:', error);
// Handle network error
}
```
### Complete Frontend Example
Example with **client-side validation** before API call:
```typescript
// apps/dashboard/src/routes/course/+page.svelte
<script lang="ts">
import { classroomio } from '$lib/utils/services/api';
import { ZCourseCreate } from '@cio/utils/validation/course';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { goto } from '$app/navigation';
let fields = $state({ title: '', slug: '', description: '' });
let errors = $state<Record<string, string>>({});
let loading = $state(false);
const handleSubmit = async () => {
loading = true;
errors = {};
// Client-side validation with entity-specific translations
const result = ZCourseCreate.safeParse(fields);
if (!result.success) {
// Map validation errors to translated messages
errors = mapZodErrorsToTranslations(result.error, 'course');
// errors.title = "Course title is required"
// errors.slug = "Course slug is required"
loading = false;
return;
}
try {
const response = await classroomio.course.create.$post({
json: result.data // Send validated data
});
const data = await response.json();
if (!data.success) {
// Handle API error (e.g., duplicate slug)
if (data.field) {
errors[data.field] = data.error;
} else {
errors.general = data.error;
}
loading = false;
return;
}
// Success - redirect to course
const { course } = data.data;
await goto(`/courses/${course.slug}`);
} catch (error) {
console.error('Error creating course:', error);
errors.general = 'Network error. Please try again.';
} finally {
loading = false;
}
};
</script>
<form onsubmit={handleSubmit}>
<div>
<label for="title">Course Title</label>
<input
id="title"
bind:value={fields.title}
class:error={errors.title}
/>
{#if errors.title}
<span class="error-message">{errors.title}</span>
{/if}
</div>
<div>
<label for="slug">Course Slug</label>
<input
id="slug"
bind:value={fields.slug}
class:error={errors.slug}
/>
{#if errors.slug}
<span class="error-message">{errors.slug}</span>
{/if}
</div>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Course'}
</button>
</form>
```
### API Abstraction Pattern (API Classes)
When creating API abstractions in `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`, **always use the `execute` method** from `BaseApi` or `BaseApiWithErrors` classes. This provides automatic loading state management, error handling, and consistent response processing.
**Important**:
- **`.svelte.ts` files** are for **client-side use only** (Svelte components). They use Svelte reactivity (`$state`, `$derived`) and manage component state. **Never** include API key logic in these files.
- **`.server.ts` files** are for **server-side use only** (`+layout.server.ts`, `+page.server.ts`, `+server.ts`). They contain static methods with API key authentication and are automatically excluded from client bundles by SvelteKit.
#### Using the `execute` Method
**Pattern:**
```typescript
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
class MyApi extends BaseApiWithErrors {
async updateItem(id: string, data: Partial<Item>) {
await this.execute<typeof classroomio.items[':id']['$put']>({
requestFn: () => classroomio.items[':id'].$put({
param: { id },
json: data
}),
logContext: 'updating item',
onSuccess: (response) => {
// Update local stores with response.data
itemsStore.update((items) => {
const index = items.findIndex((i) => i.id === id);
if (index !== -1 && response.data) {
items[index] = { ...items[index], ...response.data };
}
return items;
});
},
onError: (result) => {
// Optional: Custom error handling
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result) {
this.handleValidationError(result);
}
}
});
}
}
export const myApi = new MyApi();
```
**Key Points:**
- **Always use `this.execute`** - Never call the RPC client directly
- **Type the execute call** with `typeof classroomio.{route}.{method}` for type safety
- **Use `requestFn`** - Wrap the RPC call in a function
- **Use `logContext`** - Provide descriptive context for error logging
- **Use `onSuccess`** - Update local stores/reactivity with response data
- **Use `onError`** - Optional custom error handling (validation errors are handled automatically by `BaseApiWithErrors`)
**Examples:**
```typescript
// Profile API - Simple update
await this.execute<typeof classroomio.account.user.$put>({
requestFn: () => classroomio.account.user.$put({ json: profileUpdates }),
logContext: 'updating profile',
onSuccess: (response) => {
profileStore.update((_profile) => ({
..._profile,
...response.profile
}));
}
});
// Onboarding API - With error handling
await this.execute<typeof classroomio.onboarding.step1.$post>({
requestFn: () => classroomio.onboarding.step1.$post({ json: data }),
logContext: 'submitting organization setup',
onSuccess: (result) => {
const { organizations } = result.data;
orgs.set(organizations);
currentOrg.set(organizations[0]);
this.errors = {};
this.step = ONBOARDING_STEPS.USER_METADATA;
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result) {
this.handleValidationError(result);
}
}
});
```
**Benefits:**
- ✅ Automatic loading state management (`this.isLoading`)
- ✅ Automatic error state management (`this.error` or `this.errors`)
- ✅ Consistent error handling across all API calls
- ✅ Type-safe responses
- ✅ Centralized logging
- ✅ Easy to test and mock
### Server-Side API Calls
**For server-side files** (`+layout.server.ts`, `+page.server.ts`, `+server.ts`), use **`.server.ts` API classes** instead of calling the RPC client directly. This prevents API keys from being accidentally bundled into client-side code.
#### Pattern for Server-Side Files
Create a `.server.ts` file alongside your `.svelte.ts` API class with static methods that use API key authentication:
```typescript
// apps/dashboard/src/lib/features/org/api/org.server.ts
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgApiServer {
/**
* Gets organization by siteName (server-side)
* @param siteName Organization site name
* @returns Organization or null
*/
static async getOrgBySiteName(siteName: string) {
try {
const response = await classroomio.organization.$get(
{
query: { siteName }
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
} catch (error) {
console.error('Error fetching organization by siteName (server):', error);
return null;
}
}
}
```
**Usage in server-side files:**
```typescript
// apps/dashboard/src/routes/invite/t/[hash]/+layout.server.ts
import { OrgApiServer } from '$features/org/api/org.server';
export const load = async ({ params }) => {
// Use static method from .server.ts class
const org = await OrgApiServer.getOrgBySiteName(orgSiteName);
return { org };
};
```
#### API Key Authentication for Webhooks/External Calls
For server-to-server communication (webhooks, external integrations), create `.server.ts` files with static methods:
```typescript
// apps/dashboard/src/lib/features/org/api/org-plan.server.ts
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import type { TCreateOrgPlan } from '@cio/utils/validation/organization';
/**
* Server-side API methods for organization plan operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgPlanApiServer {
/**
* Creates a new organization plan (server-side)
* @param params Organization plan creation parameters
* @returns Response data or null on error
*/
static async createOrgPlan(params: TCreateOrgPlan) {
try {
const response = await classroomio.organization.plan.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error creating org plan (server):', error);
return null;
}
}
}
```
**Usage in webhook handlers:**
```typescript
// apps/dashboard/src/routes/api/polar/webhook/+server.ts
import { OrgPlanApiServer } from '$features/org/api/org-plan.server';
// Use static method from .server.ts class
const result = await OrgPlanApiServer.createOrgPlan(planData);
```
#### File Naming Convention
- **`.svelte.ts`**: Client-side API classes (for Svelte components)
- Example: `org.svelte.ts`, `org-plan.svelte.ts`
- Uses Svelte reactivity (`$state`, `$derived`)
- Manages component state and loading/error states
- **Never** contains API key logic
- **`.server.ts`**: Server-side API classes (for server-side files)
- Example: `org.server.ts`, `org-plan.server.ts`
- Contains static methods with API key authentication
- **Only** used in `+layout.server.ts`, `+page.server.ts`, `+server.ts` files
- Automatically excluded from client bundles by SvelteKit
#### API Key Setup
1. Add `API_KEY` to `apps/api/src/config/env.ts` environment schema
2. Set `API_KEY` environment variable in your deployment
3. Use `authOrApiKeyMiddleware` in API routes that accept both user sessions and API keys
4. Create `.server.ts` files with static methods that use `getApiKeyHeaders()` helper
**Helper Function**: Use `getApiKeyHeaders()` from `$lib/utils/services/api/server` to get API key headers. This helper:
- Returns the correct headers format for RPC client
- Validates that `API_KEY` is configured
- Provides consistent API key authentication across all server-side files
#### Why This Pattern?
- ✅ **Security**: API keys are isolated in `.server.ts` files that cannot be imported on the client
- ✅ **Clear separation**: Client-side (`.svelte.ts`) vs server-side (`.server.ts`) is explicit
- ✅ **Type-safe**: Full TypeScript support end-to-end
- ✅ **Consistent**: Same API routes, same validation, same error handling
- ✅ **Maintainable**: Centralized server-side API methods in dedicated files
- ✅ **SvelteKit convention**: `.server.ts` files are automatically excluded from client bundles
### Frontend Best Practices
#### ✅ DO
- **Use type-safe RPC client** for API calls (Hono RPC)
- **Always use `execute` method** in API abstraction classes (never call RPC client directly)
- **Use `.server.ts` API classes** in server-side files (`+layout.server.ts`, `+page.server.ts`, `+server.ts`)
- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication
- **Use API key** for server-to-server communication (webhooks, external calls)
- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation`
- **Use entity-specific translations** when multiple entities share field names
```typescript
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
```
- **Provide generic translations** as fallbacks for shared fields (email, password)
- **Handle both API errors and network errors** separately
- **Show specific field errors** when validation or API returns them
- **Keep UI responsive** during API calls (loading states)
- **Optimistically update UI** when appropriate
- **Handle different HTTP status codes** appropriately
- **Show user-friendly, translated error messages**
#### ❌ DON'T
- **Call RPC client directly** in API classes (always use `execute` method)
- **Call RPC client directly in server-side files** - use `.server.ts` API classes instead
- **Use `.svelte.ts` API classes in server-side files** - they're for client-side Svelte components only
- **Include API key logic in `.svelte.ts` files** - API keys must only be in `.server.ts` files
- Skip error handling assuming API calls always succeed
- Display raw API error messages without formatting
- Skip client-side validation (validate before API call for instant feedback)
- Hardcode error messages in schemas (use translation system)
- Use the same generic message for all entities (use entity-specific when needed)
- Make multiple simultaneous calls that should be atomic
- Store sensitive data in frontend state longer than necessary
- Retry failed requests automatically without user action (for mutations)
- Trust client-side validation alone (API always validates too)
---
## Public API Considerations
Since routes may become public APIs, consider these additional aspects:
### 1. **API Versioning**
Prepare for versioning from the start:
```typescript
// Current approach (v1 implied)
.route('/account', accountRouter)
// When versioning becomes necessary
.route('/v1/account', accountRouterV1)
.route('/v2/account', accountRouterV2)
```
### 2. **Rate Limiting**
Already implemented globally, but consider per-route limits:
```typescript
import rateLimiter from '@api/middlewares/rate-limiter';
// Higher rate limit for read operations
.get('/courses', rateLimiter({ limit: 100, window: 60000 }), async (c) => {})
// Lower rate limit for write operations
.post('/create', rateLimiter({ limit: 10, window: 60000 }), async (c) => {})
```
### 3. **API Documentation**
Add JSDoc comments to document your routes:
```typescript
/**
* POST /api/onboarding/create-org
* Creates organization during onboarding
*
* @requires Authentication
* @returns {201} Created - Organization created successfully
* @returns {409} Conflict - Sitename already exists
*/
.post('/create-org', authMiddleware, zValidator('json', createOrgSchema), async (c) => {
// ... implementation
});
```
### 4. **Pagination Standards**
For list endpoints, use consistent pagination:
```typescript
const paginationSchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1)).default('1'),
limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1).max(100)).default('20'),
sort: z.enum(['asc', 'desc']).default('desc'),
sortBy: z.string().default('createdAt')
});
.get('/list', zValidator('query', paginationSchema), async (c) => {
const { page, limit, sort, sortBy } = c.req.valid('query');
const offset = (page - 1) * limit;
const items = await getItems({ offset, limit, sort, sortBy });
const total = await getItemsCount();
return c.json({
success: true,
data: items,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasMore: page * limit < total
}
});
});
```
### 5. **Filtering and Search**
Standard query parameter naming:
```typescript
const searchSchema = z.object({
q: z.string().optional(), // General search query
filter: z.string().optional(), // Specific field filter (JSON or comma-separated)
status: z.enum(['active', 'inactive', 'archived']).optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional()
});
```
### 6. **Security**
Ensure the following (already configured globally in `app.ts`):
- CORS properly configured for trusted origins
- Security headers set
- Rate limiting appropriate for endpoint
- Authentication enforced where needed
---
## Testing Routes
### Unit Testing Queries
```typescript
// packages/db/src/queries/__tests__/organization.test.ts
import { describe, it, expect } from 'vitest';
import { createOrganization, checkSiteNameExists } from '../organization';
describe('Organization Queries', () => {
it('should create an organization', async () => {
const org = await createOrganization({
name: 'Test Org',
siteName: 'test-org'
});
expect(org).toHaveProperty('id');
expect(org.name).toBe('Test Org');
});
it('should check if sitename exists', async () => {
await createOrganization({ name: 'Test Org', siteName: 'test-org' });
expect(await checkSiteNameExists('test-org')).toBe(true);
expect(await checkSiteNameExists('other-org')).toBe(false);
});
});
```
### Integration Testing Routes
```typescript
// apps/api/src/routes/__tests__/onboarding.test.ts
import { describe, it, expect } from 'vitest';
import { app } from '@api/app';
describe('Onboarding Routes', () => {
it('should create organization with valid data', async () => {
const res = await app.request('/onboarding/create-org', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fullname: 'John Doe',
orgName: 'Test School',
siteName: 'test-school'
})
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.success).toBe(true);
});
it('should return 409 for duplicate sitename', async () => {
// Create first, then attempt duplicate
const res = await app.request('/onboarding/create-org', {
method: 'POST',
body: JSON.stringify({
/* duplicate data */
})
});
expect(res.status).toBe(409);
expect((await res.json()).code).toBe('SITENAME_EXISTS');
});
});
```
---
## Checklist for New Routes
Before marking a route as complete, ensure:
- [ ] **Queries Layer**
- [ ] Queries are in appropriate `packages/db/src/queries/{domain}` folder
- [ ] All queries are pure functions (no side effects)
- [ ] Queries use Drizzle ORM with type inference
- [ ] **Use exported types from `@cio/db/types`** (`TNew{Table}`, `T{Table}`)
- [ ] Use `TNew{Table}` for creates, `Partial<T{Table}>` for updates
- [ ] No runtime validation in queries (validation happens at API route layer)
- [ ] JSDoc comments added to all exported functions
- [ ] Queries are exported in index files
- [ ] **Service Layer**
- [ ] Service file created in `apps/api/src/services/{domain}.ts`
- [ ] Business logic moved from routes to services
- [ ] `AppError` used with appropriate error codes and status codes
- [ ] Transactions handled in services
- [ ] Business rules validated (e.g., uniqueness checks)
- [ ] Services return structured data (not HTTP responses)
- [ ] JSDoc comments explaining business logic
- [ ] Services exported in index files
- [ ] **Route Layer**
- [ ] Route file created in `apps/api/src/routes/{domain}`
- [ ] All inputs validated with Zod schemas
- [ ] **Validation schemas defined in `packages/utils/src/validation/{entity}/`**
- [ ] Schemas imported from `@cio/utils/validation/{entity}` in route handlers
- [ ] Schemas are clean (no hardcoded error messages)
- [ ] Translation keys documented in schema comments
- [ ] Authentication middleware applied where needed
- [ ] Routes call services (not queries directly)
- [ ] **`handleError` utility used in all catch blocks**
- [ ] Custom fallback messages provided to `handleError` for context
- [ ] Consistent response format used (`{ success: true/false, data/error, code? }`)
- [ ] Route documented with JSDoc comments
- [ ] **Registration**
- [ ] Router exported in route's `index.ts`
- [ ] Route registered in main `app.ts`
- [ ] **Frontend**
- [ ] RPC client calls replace direct database calls
- [ ] Shared validation schemas imported from `@cio/utils/validation`
- [ ] Client-side validation before API calls
- [ ] Entity-specific translations used when needed (e.g., `mapZodErrorsToTranslations(error, 'course')`)
- [ ] Generic translations provided as fallbacks
- [ ] Translation keys follow convention: `validations.{entity}.{field}.{errorCode}`
- [ ] Error handling for both validation, API, and network errors
- [ ] Loading states implemented
- [ ] Field-specific errors displayed
- [ ] User-friendly, translated error messages shown
- [ ] **Public API Ready**
- [ ] Route can work independently of frontend
- [ ] No sensitive information exposed in responses
- [ ] Rate limiting appropriate for endpoint
- [ ] Documentation complete and accurate
- [ ] Versioning strategy considered
- [ ] **Testing** (Optional but recommended)
- [ ] Query unit tests written
- [ ] Route integration tests written
- [ ] Error cases tested
- [ ] Authentication/authorization tested
---
## Common Patterns
### Transactional Operations
```typescript
const result = await db.transaction(async (tx) => {
const org = await createOrganization({ name, siteName });
const member = await createOrganizationMember({ orgId: org.id, userId });
return { org, member };
});
```
### Authorization Checks
```typescript
.patch('/update/:id', authMiddleware, async (c) => {
const id = c.req.param('id');
const user = c.get('user');
const hasAccess = await checkUserAccessToOrg(user.id, id);
if (!hasAccess) {
return c.json({ success: false, error: 'Forbidden' }, 403);
}
const updated = await updateOrganization(id, updates);
return c.json({ success: true, data: updated }, 200);
});
```
### Bulk Operations
```typescript
.post('/bulk', authMiddleware, zValidator('json', z.object({
items: z.array(itemSchema).min(1).max(100)
})), async (c) => {
const { items } = c.req.valid('json');
const results = await db.transaction(async (tx) => {
return await Promise.all(items.map(item => createItem(item)));
});
return c.json({ success: true, data: results }, 201);
});
```
---
## Summary
This four-layer architecture ensures:
### **Layer Separation**
1. **Shared Validation Layer**: Zod schemas shared between API and frontend
2. **Routes (HTTP Layer)**: Handle requests/responses, validation, authentication
3. **Services (Business Logic)**: Orchestrate queries, manage transactions, business rules
4. **Queries (Data Layer)**: Pure database operations, type-safe
5. **Frontend (UI Layer)**: Type-safe API consumption via RPC with client-side validation
### **Benefits**
1. **Maintainability**: Clear separation of concerns across all layers
2. **Testability**: Each layer can be tested independently
3. **Reusability**: Queries and services can be used across multiple routes
4. **Type Safety**: End-to-end type safety from database to frontend
5. **Public API Ready**: Routes designed to be exposed as public APIs
6. **Consistency**: Standardized response formats and error handling
7. **Security**: Authentication, validation, and proper error handling
8. **Scalability**: Easy to add new features following established patterns
9. **Developer Experience**: Type-safe RPC client, clear error messages
### **Example Flow**
```txt
Frontend Form (validates with @cio/utils/validation)
↓
Frontend Request (with validated data)
↓
Route (validates input again with @cio/utils/validation, checks auth)
↓
Service (business logic, transactions)
↓
Queries (database operations)
↓
Database
↓
Queries (return type-safe data)
↓
Service (return structured results)
↓
Route (format HTTP response)
↓
Frontend Response (maps errors to translations if validation failed)
```
By following these patterns, our API remains **consistent**, **secure**, **testable**, and **ready for public consumption**.
---
## Refactoring Components to Features/UI
When moving components from `apps/dashboard/src/lib/components/` to `apps/dashboard/src/lib/features/ui/`, follow this workflow:
### Step 1: Determine Component Structure
**Single-file components**: Move directly to `features/ui/` with kebab-case naming:
- `components/CodeSnippet/index.svelte` → `features/ui/code-snippet.svelte`
**Components with related files** (stores, utils, etc.): Create a folder matching the component name:
- `components/Confetti/index.svelte` + `store.ts` → `features/ui/confetti/confetti.svelte` + `confetti-store.ts`
- `components/UploadWidget/index.svelte` + `utils.ts` → `features/ui/upload-widget/upload-widget.svelte` + `utils.ts`
**Naming Convention**:
- ✅ File name matches folder name: `confetti/confetti.svelte`, `upload-widget/upload-widget.svelte`
- ❌ Don't use `index.svelte` as the root file - always name it the same as the folder
### Step 2: Move Files
1. Create the new file(s) in `features/ui/` (or subfolder if needed)
2. Update internal imports (e.g., `./store` → `./confetti-store` within the folder)
3. Copy component code to new location
### Step 3: Update Exports
Add export to `apps/dashboard/src/lib/features/ui/index.ts`:
```typescript
export { default as ComponentName } from './component-name.svelte';
// or for folder-based components:
export { default as ComponentName } from './component-name/component-name.svelte';
```
Keep exports alphabetically sorted.
### Step 4: Update All Imports
Find all usages:
```bash
grep -r "components/OldComponent" apps/dashboard/src
```
Replace imports:
- **From**: `import Component from '$lib/components/OldComponent/index.svelte';`
- **To**: `import { Component } from '$features/ui';`
For store/utils imports:
- **From**: `import { store } from '$lib/components/OldComponent/store';`
- **To**: `import { store } from '$features/ui/component-name/store';`
### Step 5: Merge Duplicate Imports
After updating imports, check for duplicate imports from the same source and merge them:
```typescript
// Before
import { Confetti } from '$features/ui';
import { ComingSoon } from '$features/ui';
// After
import { Confetti, ComingSoon } from '$features/ui';
```
### Step 6: Clean Up
1. Delete old component files
2. Remove empty folders: `rmdir apps/dashboard/src/lib/components/OldComponent`
3. Verify no remaining references: `grep -r "components/OldComponent" apps/dashboard/src`
4. Run linter to check for errors
### Step 7: Verify
- ✅ No linter errors
- ✅ No remaining references to old path
- ✅ All imports use the new path
- ✅ Component exports correctly from `features/ui/index.ts`
- ✅ Related files (stores/utils) are properly organized
### Example: Complete Refactoring
**Before**:
```
apps/dashboard/src/lib/components/
└── Confetti/
├── index.svelte
└── store.ts
```
**After**:
```
apps/dashboard/src/lib/features/ui/
├── confetti/
│ ├── confetti.svelte
│ └── store.ts
└── index.ts (exports Confetti)
```
**Import changes**:
- Component: `import { Confetti } from '$features/ui';`
- Store: `import { toggleConfetti } from '$features/ui/confetti/store';`
This refactoring centralizes UI components in a single location, making them easier to discover, maintain, and reuse across the application.