* feat: setup dashboard analytics api * chore: requested pr fixes * fix: dash analytics server side type * refactor: update Avatar component usage across the dashboard - Replaced custom Avatar component with the new @cio/ui/base/avatar implementation for consistency. - Enhanced avatar rendering with fallback options and improved accessibility. - Cleaned up unused Avatar component files and related functions. - Updated various components to utilize the new Avatar structure, ensuring a uniform appearance and behavior across the application. * chore: remove console.log statements from analytics routes and API calls - Eliminated console.log statements for orgId and siteName in the dash analytics router and DashStatApi class to clean up the code and improve performance. --------- Co-authored-by: Rotimi Best <best@classroomio.com>
2448 lines
76 KiB
Markdown
2448 lines
76 KiB
Markdown
# 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`
|
||
|
||
### 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
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 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<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 '$lib/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 '$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<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**.
|