From 9a5aee1ee08374b4f4be1cd623a663264f102635 Mon Sep 17 00:00:00 2001 From: Adeyemi Adetunji Date: Tue, 2 Dec 2025 15:27:41 +0100 Subject: [PATCH] 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 --- ARCHITECHTURE.md | 208 ++++++++++-------- apps/api/src/app.ts | 21 +- apps/api/src/routes/dash/index.ts | 1 + apps/api/src/routes/dash/stats.ts | 23 ++ apps/api/src/services/dash.ts | 60 +++++ apps/api/src/services/index.ts | 1 + apps/api/src/types/dash.ts | 23 ++ apps/api/src/types/index.ts | 1 + apps/api/src/utils/errors.ts | 4 + .../Analytics/HeroProfileCard.svelte | 10 +- .../src/lib/components/Avatar/index.svelte | 21 -- .../components/Analytics/StudentTable.svelte | 11 +- .../Materials/components/Comments.svelte | 23 +- .../components/Editor/ReviewsForm.svelte | 11 +- .../components/CourseLandingPage/index.svelte | 19 +- .../src/lib/components/Navigation/lms.svelte | 25 +-- .../features/audience/pages/audience.svelte | 11 +- .../lib/features/org/api/dash-stats.svelte.ts | 34 +++ .../src/lib/features/org/api/index.ts | 1 + .../src/lib/features/org/utils/types.ts | 5 + .../setup/components/setup-progress.svelte | 2 +- .../src/lib/features/ui/auth-ui.svelte | 18 +- .../sidebar/org-sidebar/org-switcher.svelte | 10 +- apps/dashboard/src/lib/utils/functions/org.ts | 4 - .../src/lib/utils/functions/string.ts | 4 + .../src/lib/utils/types/analytics.ts | 20 -- .../routes/(auth)/auth-failed/+page.svelte | 17 +- .../routes/courses/[id]/people/+page.svelte | 19 +- .../routes/lms/community/[slug]/+page.svelte | 19 +- .../src/routes/org/[slug]/+layout.server.ts | 1 - .../src/routes/org/[slug]/+page.svelte | 82 +++---- .../org/[slug]/community/[slug]/+page.svelte | 19 +- packages/db/src/queries/dash/dash.ts | 81 +++++++ packages/db/src/queries/dash/index.ts | 1 + packages/db/src/queries/dash/types.ts | 19 ++ .../src/queries/organization/organization.ts | 4 + packages/db/src/types.ts | 2 + packages/utils/src/validation/dash/index.ts | 1 + packages/utils/src/validation/dash/stats.ts | 12 + packages/utils/src/validation/index.ts | 1 + 40 files changed, 573 insertions(+), 276 deletions(-) create mode 100644 apps/api/src/routes/dash/index.ts create mode 100644 apps/api/src/routes/dash/stats.ts create mode 100644 apps/api/src/services/dash.ts create mode 100644 apps/api/src/types/dash.ts delete mode 100644 apps/dashboard/src/lib/components/Avatar/index.svelte create mode 100644 apps/dashboard/src/lib/features/org/api/dash-stats.svelte.ts create mode 100644 packages/db/src/queries/dash/dash.ts create mode 100644 packages/db/src/queries/dash/index.ts create mode 100644 packages/db/src/queries/dash/types.ts create mode 100644 packages/utils/src/validation/dash/index.ts create mode 100644 packages/utils/src/validation/dash/stats.ts diff --git a/ARCHITECHTURE.md b/ARCHITECHTURE.md index 32035d8d..e9811df1 100644 --- a/ARCHITECHTURE.md +++ b/ARCHITECHTURE.md @@ -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) { 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); }); ``` diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 673ed943..5af74aba 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,20 +1,22 @@ import 'dotenv/config'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { prettyJSON } from 'hono/pretty-json'; +import { secureHeaders } from 'hono/secure-headers'; +import { auth } from '@cio/db/auth'; +import { Hono } from '@api/utils/hono'; +import rateLimiter from '@api/middlewares/rate-limiter'; import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants'; -import { Hono } from '@api/utils/hono'; -import { accountRouter } from '@api/routes/account'; -import { auth } from '@cio/db/auth'; -import { cors } from 'hono/cors'; -import { courseRouter } from '@api/routes/course'; -import { logger } from 'hono/logger'; +// --- routes import { mailRouter } from '@api/routes/mail'; +import { courseRouter } from '@api/routes/course'; +import { accountRouter } from '@api/routes/account'; +import { dashAnalyticsRouter } from '@api/routes/dash'; import { mediaRouter } from '@api/routes/media'; import { onboardingRouter } from '@api/routes/onboarding'; import { organizationRouter } from '@api/routes/organization'; -import { prettyJSON } from 'hono/pretty-json'; -import rateLimiter from '@api/middlewares/rate-limiter'; -import { secureHeaders } from 'hono/secure-headers'; // Create Hono app with chaining for RPC support export const app = new Hono() @@ -74,6 +76,7 @@ export const app = new Hono() .route('/mail', mailRouter) .route('/media', mediaRouter) .route('/organization', organizationRouter) + .route('/dash', dashAnalyticsRouter) // Error handling .onError((err, c) => { diff --git a/apps/api/src/routes/dash/index.ts b/apps/api/src/routes/dash/index.ts new file mode 100644 index 00000000..c7e87e1f --- /dev/null +++ b/apps/api/src/routes/dash/index.ts @@ -0,0 +1 @@ +export * from './stats'; diff --git a/apps/api/src/routes/dash/stats.ts b/apps/api/src/routes/dash/stats.ts new file mode 100644 index 00000000..4c6ad93d --- /dev/null +++ b/apps/api/src/routes/dash/stats.ts @@ -0,0 +1,23 @@ +import { Hono } from '@api/utils/hono'; +import { ZDashStats } from '@cio/utils/validation/dash'; +import { authMiddleware } from '@api/middlewares/auth'; +import { getOrganisationAnalytics } from '@api/services/dash'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; + +export const dashAnalyticsRouter = new Hono().post( + '/stats', + authMiddleware, + zValidator('json', ZDashStats), + async (c) => { + try { + const { orgId, siteName } = c.req.valid('json'); + + const result = await getOrganisationAnalytics(orgId, siteName); + + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to load organisation analytics'); + } + } +); diff --git a/apps/api/src/services/dash.ts b/apps/api/src/services/dash.ts new file mode 100644 index 00000000..e8935894 --- /dev/null +++ b/apps/api/src/services/dash.ts @@ -0,0 +1,60 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash'; + +import { OrganisationAnalytics } from '@api/types'; +import { getOrgIdBySiteName } from '@db/queries'; + +export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise { + const analytics: OrganisationAnalytics = { + revenue: 0, + numberOfCourses: 0, + totalStudents: 0, + topCourses: [], + enrollments: [] + }; + + let resolvedOrgId = orgId; + + if (!resolvedOrgId && siteName) { + const [org] = await getOrgIdBySiteName(siteName); + + if (org) { + resolvedOrgId = org.id; + } else { + throw new AppError('Organization not found for the given site name', ErrorCodes.ORG_NOT_FOUND, 404); + } + } + + try { + const [stats, topCourses, enrollments] = await Promise.all([ + getDashOrgStats(resolvedOrgId!), + getCourseStats(resolvedOrgId!), + getRecentEnrollments(resolvedOrgId!) + ]); + + analytics.revenue = 0; + analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0; + analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0; + + analytics.topCourses = topCourses.map((c) => ({ + id: c.courseId, + title: c.courseTitle, + enrollments: c.totalStudents, + completion: c.completionPercentage + })); + + analytics.enrollments = enrollments.map((e) => ({ + id: e.profileId, + avatarUrl: e.avatarUrl, + name: e.fullname, + courseId: e.courseId, + course: e.courseTitle, + date: e.enrolledAt + })); + + return analytics; + } catch (error) { + console.error('Failed to load organisation analytics:', error); + throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500); + } +} diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 4571d3d8..d08d2b0e 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,3 +1,4 @@ export * from './account'; export * from './media'; export * from './onboarding'; +export * from './dash'; diff --git a/apps/api/src/types/dash.ts b/apps/api/src/types/dash.ts new file mode 100644 index 00000000..309aee72 --- /dev/null +++ b/apps/api/src/types/dash.ts @@ -0,0 +1,23 @@ +export interface TopCourse { + id: string; + title: string; + enrollments: number; + completion: number; +} + +export interface Enrollment { + id: string; + avatarUrl: string | null; + name: string; + courseId: string; + course: string; + date: string; +} + +export interface OrganisationAnalytics { + revenue: number; + numberOfCourses: number; + totalStudents: number; + topCourses: TopCourse[]; + enrollments: Enrollment[]; +} diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index e6ca0bf1..96258206 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -1,2 +1,3 @@ // Re-export all types export * from './database'; +export * from './dash'; diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts index 23e99f00..418f121d 100644 --- a/apps/api/src/utils/errors.ts +++ b/apps/api/src/utils/errors.ts @@ -28,6 +28,10 @@ export interface ErrorResponse { // Error codes constants for consistency export const ErrorCodes = { + // Dashboard Analytics errors + ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED', + NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED', + // Organization errors SITENAME_EXISTS: 'SITENAME_EXISTS', ORG_CREATE_FAILED: 'ORG_CREATE_FAILED', diff --git a/apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte b/apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte index b66f16b5..e18eb7a8 100644 --- a/apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte +++ b/apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte @@ -1,5 +1,5 @@ - -{name} diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/StudentTable.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/StudentTable.svelte index e0b766b9..2d6567d2 100644 --- a/apps/dashboard/src/lib/components/Course/components/Analytics/StudentTable.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/StudentTable.svelte @@ -8,8 +8,9 @@ import { t } from '$lib/utils/functions/translations'; import EmptyState from './EmptyState.svelte'; - import Avatar from '$lib/components/Avatar/index.svelte'; + import * as Avatar from '@cio/ui/base/avatar'; import type { StudentOverview } from '$lib/utils/types/analytics'; + import { shortenName } from '$lib/utils/functions/string'; interface Props { students?: StudentOverview[]; @@ -97,7 +98,13 @@ class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700" >
- + + + {shortenName(student.profile.fullname) || 'S'} +

{student.profile.fullname} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte index 6109ddfb..b3c42762 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/components/Comments.svelte @@ -5,8 +5,9 @@ import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js'; import { group } from '$lib/components/Course/store'; - import Avatar from '$lib/components/Avatar/index.svelte'; + import * as Avatar from '@cio/ui/base/avatar'; import TextArea from '$lib/components/Form/TextArea.svelte'; + import { shortenName } from '$lib/utils/functions/string'; import DeleteModal from '$lib/components/Modal/DeleteModal.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons'; @@ -62,7 +63,7 @@ id: 0, comment: comment, name: $t('course.navItem.lessons.comments.you'), - avatar: $profile.avatar_url, + avatar: $profile.avatarUrl, commentAt: new Date(), groupmember_id: groupmember.id }, @@ -270,7 +271,13 @@

- + + + {shortenName($profile.fullname) || 'U'} +