# 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` types** for update operations (e.g., `Partial`) - 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` ### 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; // 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 ) // ... }); // 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 ` 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; ``` **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) { 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: 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>({}); 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; ``` **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({ 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 }); } ``` --- ### 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: 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 path conventions #### 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({ 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 ``` **After (API Route):** ```typescript ``` **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: 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(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
{#if errors.title} {errors.title} {/if}
{#if errors.slug} {errors.slug} {/if}
``` ### 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) { await this.execute({ 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({ 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({ 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 '$lib/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 '$lib/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` 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**.