feat: setup dashboard analytics api (#604)

* 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 <[email protected]>
This commit is contained in:
Adeyemi Adetunji
2025-12-02 15:27:41 +01:00
committed by GitHub
co-authored by Rotimi Best
parent c7854efae3
commit 9a5aee1ee0
40 changed files with 573 additions and 276 deletions
+111 -97
View File
@@ -74,8 +74,8 @@ Types are automatically generated from your Drizzle schema using Drizzle's built
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 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;
@@ -130,11 +130,8 @@ import type { TNewOrganization, TOrganization } from '@db/types';
* @returns Created organization record
*/
export const createOrganization = async (data: TNewOrganization) => {
const [organization] = await db
.insert(schema.organization)
.values(data)
.returning();
const [organization] = await db.insert(schema.organization).values(data).returning();
return organization;
};
@@ -150,7 +147,7 @@ export const updateOrganization = async (id: string, data: TOrganization) => {
.set(data)
.where(eq(schema.organization.id, id))
.returning();
return organization;
};
```
@@ -242,11 +239,7 @@ export async function createOrganizationWithOwner(
// 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
);
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
// Business Logic: Create org and member in a transaction
@@ -278,11 +271,7 @@ export async function createOrganizationWithOwner(
} 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(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
}
@@ -302,7 +291,7 @@ export class AppError extends Error {
message: string,
public code: string,
public statusCode: ContentfulStatusCode = 500,
public field?: string // Optional: for field-specific validation errors
public field?: string // Optional: for field-specific validation errors
) {
super(message);
this.name = 'AppError';
@@ -312,7 +301,7 @@ export class AppError extends Error {
export const ErrorCodes = {
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND'
// ... add more as needed
} as const;
```
@@ -334,19 +323,25 @@ export const handleError = (
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: error.message,
code: error.code,
field: error.field
},
error.statusCode
);
}
return c.json({
success: false,
error: fallbackMessage,
code: fallbackCode
}, 500);
return c.json(
{
success: false,
error: fallbackMessage,
code: fallbackCode
},
500
);
};
```
@@ -466,20 +461,20 @@ All API responses should follow a consistent structure:
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 |
| 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**
@@ -533,8 +528,8 @@ 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
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)
@@ -552,11 +547,10 @@ export * from './course';
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
});
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**:
@@ -599,11 +593,11 @@ This allows different messages for shared field names (e.g., "Course title is re
"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",
@@ -618,11 +612,11 @@ This allows different messages for shared field names (e.g., "Course title is re
```typescript
// With entity name (entity-specific messages)
mapZodErrorsToTranslations(error, 'course') // "Course title is required"
mapZodErrorsToTranslations(error, 'lesson') // "Lesson title is required"
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
mapZodErrorsToTranslations(error, 'lesson'); // "Lesson title is required"
// Without entity name (generic messages)
mapZodErrorsToTranslations(error) // "Title is required"
mapZodErrorsToTranslations(error); // "Title is required"
```
**Key Principles**:
@@ -703,7 +697,7 @@ Skip entity names when:
"too_small": "Lesson must be at least 1 minute long"
}
},
// 2. Generic field-level (fallback for common fields)
"title": {
"too_small": "Title is required"
@@ -714,7 +708,7 @@ Skip entity names when:
"password": {
"too_small": "Password must be at least 8 characters"
},
// 3. Generic error types (last resort fallback)
"generic": {
"required": "This field is required",
@@ -776,7 +770,7 @@ if (!result.success) {
✅ **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
✅ **Professional UX**: Context-specific messages improve user experience
---
@@ -843,8 +837,11 @@ 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) => {
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');
@@ -855,7 +852,8 @@ export const organizationRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
});
}
);
```
**Key Points:**
@@ -942,12 +940,12 @@ export async function updateUser(userId: string, data: Partial<TProfile>) {
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);
}
}
@@ -967,18 +965,17 @@ 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);
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');
}
});
return c.json({ success: true, profile: updatedProfile }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update profile');
}
});
```
---
@@ -992,13 +989,20 @@ 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
export const app = new Hono().route('/account', accountRouter); // Route available at PUT /account/user
```
---
### Step 5: Use in Frontend
### 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`
@@ -1021,15 +1025,15 @@ export class ProfileApi {
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;
@@ -1637,19 +1641,27 @@ import { onboardingRouter } from '@api/routes/onboarding';
export const app = new Hono()
// ... middleware ...
// Routes
.route('/account', accountRouter)
.route('/onboarding', onboardingRouter) // Add new route
.route('/onboarding', onboardingRouter) // Add new route
.route('/course', courseRouter)
.route('/mail', mailRouter)
// ... error handling ...
.route('/mail', mailRouter);
// ... error handling ...
```
---
## Step 5: Update Frontend
### 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)
@@ -1732,7 +1744,7 @@ Example with **client-side validation** before API call:
// 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');
@@ -1763,7 +1775,7 @@ Example with **client-side validation** before API call:
// 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.';
@@ -2062,7 +2074,7 @@ const result = await OrgPlanApiServer.createOrgPlan(planData);
- **Use entity-specific translations** when multiple entities share field names
```typescript
mapZodErrorsToTranslations(error, 'course') // "Course title is required"
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
```
- **Provide generic translations** as fallbacks for shared fields (email, password)
@@ -2130,7 +2142,7 @@ Add JSDoc comments to document your routes:
/**
* POST /api/onboarding/create-org
* Creates organization during onboarding
*
*
* @requires Authentication
* @returns {201} Created - Organization created successfully
* @returns {409} Conflict - Sitename already exists
@@ -2154,11 +2166,11 @@ const paginationSchema = z.object({
.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,
@@ -2255,7 +2267,9 @@ describe('Onboarding Routes', () => {
// Create first, then attempt duplicate
const res = await app.request('/onboarding/create-org', {
method: 'POST',
body: JSON.stringify({ /* duplicate data */ })
body: JSON.stringify({
/* duplicate data */
})
});
expect(res.status).toBe(409);
@@ -2371,11 +2385,11 @@ const result = await db.transaction(async (tx) => {
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);
});
```