diff --git a/apps/api/src/routes/organization/member-email-notifications.ts b/apps/api/src/routes/organization/member-email-notifications.ts new file mode 100644 index 00000000..77e03907 --- /dev/null +++ b/apps/api/src/routes/organization/member-email-notifications.ts @@ -0,0 +1,48 @@ +import { Hono } from '@api/utils/hono'; +import { authMiddleware } from '@api/middlewares/auth'; +import { orgMemberMiddleware } from '@api/middlewares/org-member'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; +import { ZMemberEmailNotificationPreferencesUpdate } from '@cio/utils/validation/notifications'; + +import { + getMemberEmailNotificationPreferences, + updateMemberEmailNotificationPreferences +} from '@api/services/notification/member-email-notifications'; + +/** + * Per-org personal email notification preferences for the authenticated member. + * + * GET /organization/member/email-notifications + * PUT /organization/member/email-notifications + */ +export const organizationMemberEmailNotificationsRouter = new Hono() + .get('/', authMiddleware, orgMemberMiddleware, async (c) => { + try { + const user = c.get('user')!; + const orgId = c.req.header('cio-org-id')!; + const preferences = await getMemberEmailNotificationPreferences(user.id, orgId); + + return c.json({ success: true as const, data: preferences }); + } catch (error) { + return handleError(c, error, 'Failed to fetch email notification preferences'); + } + }) + .put( + '/', + authMiddleware, + orgMemberMiddleware, + zValidator('json', ZMemberEmailNotificationPreferencesUpdate), + async (c) => { + try { + const user = c.get('user')!; + const orgId = c.req.header('cio-org-id')!; + const preferences = c.req.valid('json'); + const updated = await updateMemberEmailNotificationPreferences(user.id, orgId, preferences); + + return c.json({ success: true as const, data: updated }); + } catch (error) { + return handleError(c, error, 'Failed to update email notification preferences'); + } + } + ); diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index eb568def..0477e13a 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -52,6 +52,7 @@ import { TOrganization } from '@db/types'; import { assetsRouter } from '@api/routes/organization/assets'; import { autoJoinOrg } from '@api/services/organization/auto-join'; import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor'; +import { organizationMemberEmailNotificationsRouter } from '@api/routes/organization/member-email-notifications'; import { authMiddleware } from '@api/middlewares/auth'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; @@ -768,4 +769,5 @@ export const organizationRouter = new Hono() .route('/widgets', widgetsRouter) .route('/assets', assetsRouter) .route('/ai-tutor', organizationAiTutorRouter) + .route('/member/email-notifications', organizationMemberEmailNotificationsRouter) .route('/:orgId/quiz', quizRouter); diff --git a/apps/api/src/services/account/profile.ts b/apps/api/src/services/account/profile.ts index dd9050c7..10d61d98 100644 --- a/apps/api/src/services/account/profile.ts +++ b/apps/api/src/services/account/profile.ts @@ -100,16 +100,7 @@ export async function getAccountData(userId: string): Promise> = { - ...profileFields - }; - - if (emailNotifications !== undefined) { - updatePayload.settings = { emailNotifications }; - } - - const updatedProfile = await updateProfile(userId, updatePayload); + const updatedProfile = await updateProfile(userId, data); if (!updatedProfile) { throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404); diff --git a/apps/api/src/services/notification/member-email-notifications.ts b/apps/api/src/services/notification/member-email-notifications.ts new file mode 100644 index 00000000..b5742a31 --- /dev/null +++ b/apps/api/src/services/notification/member-email-notifications.ts @@ -0,0 +1,29 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { + getOrganizationMemberEmailNotificationsForApi, + upsertOrganizationMemberEmailNotifications +} from '@cio/db/queries/notifications'; +import { memberEmailNotificationRowToApiResponse } from '@cio/utils/notifications'; +import type { TMemberEmailNotificationPreferencesUpdate } from '@cio/utils/validation/notifications'; + +export async function getMemberEmailNotificationPreferences(profileId: string, organizationId: string) { + return getOrganizationMemberEmailNotificationsForApi(profileId, organizationId); +} + +export async function updateMemberEmailNotificationPreferences( + profileId: string, + organizationId: string, + preferences: TMemberEmailNotificationPreferencesUpdate +) { + try { + const updated = await upsertOrganizationMemberEmailNotifications(profileId, organizationId, preferences); + + return memberEmailNotificationRowToApiResponse(updated); + } catch (error) { + if (error instanceof Error && error.message === 'Organization member not found') { + throw new AppError('Organization member not found', ErrorCodes.NOT_FOUND, 404); + } + + throw error; + } +} diff --git a/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts b/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts index 9e111add..fbe7ec7e 100644 --- a/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts +++ b/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts @@ -137,30 +137,6 @@ export class ProfileApi extends BaseApiWithErrors { } } - async updateEmailNotifications(emailNotifications: Record) { - try { - this.isLoading = true; - this.errors = {}; - this.success = false; - - await this.execute({ - requestFn: () => classroomio.account.profile.$put({ json: { emailNotifications } }), - logContext: 'updating email notification preferences', - onSuccess: (response) => { - profileStore.update((_profile) => ({ - ..._profile, - ...response.profile - })); - this.success = true; - } - }); - } catch (error) { - this.handleError(error); - } finally { - this.isLoading = false; - } - } - async changeEmail(fields: TChangeEmailForm) { // Validate email format const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail }); diff --git a/apps/dashboard/src/lib/features/course/components/course-header.svelte b/apps/dashboard/src/lib/features/course/components/course-header.svelte index 18ccd09a..e51c1c1e 100644 --- a/apps/dashboard/src/lib/features/course/components/course-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-header.svelte @@ -17,6 +17,7 @@ import { toggleAiAssistant } from '$features/ai-assistant/utils/store'; import { openCoursePreview } from '$features/course/utils/course-preview'; import { t } from '$lib/utils/functions/translations'; + import CourseProgressPopover from './course-progress-popover.svelte'; import CoursePublishBadge from './course-publish-badge.svelte'; import CoursePublicBadge from './course-public-badge.svelte'; import CourseContextMenuContent from './course-context-menu-content.svelte'; @@ -85,6 +86,10 @@ + {#if $isStudentExperience} + + {/if} + - {/if} + {/if} diff --git a/apps/dashboard/src/lib/features/course/utils/content.ts b/apps/dashboard/src/lib/features/course/utils/content.ts index 8abfcb07..ca6269ee 100644 --- a/apps/dashboard/src/lib/features/course/utils/content.ts +++ b/apps/dashboard/src/lib/features/course/utils/content.ts @@ -56,6 +56,42 @@ export function getCourseContent(course: Course | null) { const NAVIGABLE_CONTENT_TYPES = [CourseContentType.Lesson, CourseContentType.Exercise] as const; +export type ContentProgress = { + lessonsTotal: number; + lessonsComplete: number; + exercisesTotal: number; + exercisesComplete: number; + total: number; + completed: number; + percent: number; +}; + +export function getContentItemsProgress(items: ContentItem[]): ContentProgress { + const lessons = items.filter((item) => item.type === CourseContentType.Lesson); + const exercises = items.filter((item) => item.type === CourseContentType.Exercise); + const lessonsComplete = lessons.filter((item) => item.isComplete).length; + const exercisesComplete = exercises.filter((item) => item.isComplete).length; + const total = lessons.length + exercises.length; + const completed = lessonsComplete + exercisesComplete; + const percent = total === 0 ? 0 : Math.round((completed / total) * 100); + + return { + lessonsTotal: lessons.length, + lessonsComplete, + exercisesTotal: exercises.length, + exercisesComplete, + total, + completed, + percent + }; +} + +export function getCourseProgress(course: Course | null): ContentProgress { + const content = getCourseContent(course); + const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items; + return getContentItemsProgress(items); +} + export function getOrderedNavigableContent(course: Course | null): ContentItem[] { const content = getCourseContent(course); const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items; diff --git a/apps/dashboard/src/lib/features/settings/api/member-email-notifications.svelte.ts b/apps/dashboard/src/lib/features/settings/api/member-email-notifications.svelte.ts new file mode 100644 index 00000000..5cde737a --- /dev/null +++ b/apps/dashboard/src/lib/features/settings/api/member-email-notifications.svelte.ts @@ -0,0 +1,23 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import type { + GetMemberEmailNotificationsRequest, + UpdateMemberEmailNotificationsRequest +} from '$features/settings/utils/types'; + +class MemberEmailNotificationsApi extends BaseApiWithErrors { + async fetch() { + return this.execute({ + requestFn: () => classroomio.organization.member['email-notifications'].$get(), + logContext: 'fetching member email notification preferences' + }); + } + + async update(preferences: Record) { + return this.execute({ + requestFn: () => classroomio.organization.member['email-notifications'].$put({ json: preferences }), + logContext: 'updating member email notification preferences' + }); + } +} + +export const memberEmailNotificationsApi = new MemberEmailNotificationsApi(); diff --git a/apps/dashboard/src/lib/features/settings/pages/notifications.svelte b/apps/dashboard/src/lib/features/settings/pages/notifications.svelte index 406a138b..2c1c38ae 100644 --- a/apps/dashboard/src/lib/features/settings/pages/notifications.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/notifications.svelte @@ -6,12 +6,11 @@ PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS } from '@cio/utils/notifications'; - import { profile } from '$lib/utils/store/user'; import { currentOrg, isOrgAdmin } from '$lib/utils/store/org'; import { isOrgStudent } from '$lib/utils/store/app'; import { t } from '$lib/utils/functions/translations'; import { snackbar } from '$features/ui/snackbar/store'; - import { profileApi } from '$features/auth/api/profile.svelte'; + import { memberEmailNotificationsApi } from '$features/settings/api/member-email-notifications.svelte'; import { orgApi } from '$features/org/api/org.svelte'; import * as Field from '@cio/ui/base/field'; import { Switch } from '@cio/ui/base/switch'; @@ -26,10 +25,12 @@ } let personalToggles = $state(buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS)); + let savedPersonalToggles = $state(buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS)); let orgToggles = $state(buildToggleState(EMAIL_NOTIFICATION_TOGGLE_KEYS)); let isSavingPersonal = $state(false); let isSavingOrg = $state(false); - let hasHydratedFromStores = $state(false); + let isLoadingPersonal = $state(false); + let hasHydratedOrgToggles = $state(false); const tutorOnlyToggleKeys = new Set(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS); @@ -42,12 +43,18 @@ : PERSONAL_EMAIL_NOTIFICATION_SECTIONS ); - function syncPersonalTogglesFromProfile() { - const profileState = get(profile); - const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, profileState.settings?.emailNotifications); + const personalDescription = $derived( + $currentOrg?.name + ? t.get('settings.notifications.personal.description_with_org', { orgName: $currentOrg.name }) + : t.get('settings.notifications.personal.description') + ); + + function applyPersonalToggleState(source?: Partial> | null) { + const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, source); for (const key of PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS) { personalToggles[key] = next[key]; + savedPersonalToggles[key] = next[key]; } } @@ -63,41 +70,53 @@ } } - function tryHydrateFromStores() { - if (hasHydratedFromStores) return; + async function loadPersonalPreferences() { + if (!$currentOrg?.id) return; - const profileState = get(profile); - if (!profileState.id) return; + isLoadingPersonal = true; - syncPersonalTogglesFromProfile(); + const result = await memberEmailNotificationsApi.fetch(); - if (get(isOrgAdmin)) { - if (!get(currentOrg)?.id) return; - syncOrgTogglesFromCurrentOrg(); + if (result?.data) { + applyPersonalToggleState(result.data); } - hasHydratedFromStores = true; + isLoadingPersonal = false; + } + + function tryHydrateOrgToggles() { + if (hasHydratedOrgToggles || !get(isOrgAdmin)) return; + + if (!get(currentOrg)?.id) return; + + syncOrgTogglesFromCurrentOrg(); + hasHydratedOrgToggles = true; } onMount(() => { - tryHydrateFromStores(); + tryHydrateOrgToggles(); }); $effect(() => { - if (hasHydratedFromStores) return; + const orgId = $currentOrg?.id; - const profileId = $profile.id; - const orgId = $isOrgAdmin ? $currentOrg?.id : 'ready'; + if (!orgId) return; - if (!profileId || ($isOrgAdmin && !orgId)) return; + void loadPersonalPreferences(); + }); - tryHydrateFromStores(); + $effect(() => { + if (hasHydratedOrgToggles) return; + + const orgId = $isOrgAdmin ? $currentOrg?.id : null; + + if (!orgId) return; + + tryHydrateOrgToggles(); }); const personalHasChanges = $derived( - PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some( - (key) => personalToggles[key] !== ($profile.settings?.emailNotifications?.[key] !== false) - ) + PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some((key) => personalToggles[key] !== savedPersonalToggles[key]) ); const orgHasChanges = $derived( @@ -117,14 +136,16 @@ } async function handleSavePersonal() { + if (!$currentOrg?.id) return; + isSavingPersonal = true; - await profileApi.updateEmailNotifications( + const result = await memberEmailNotificationsApi.update( toStoredPreferences(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, personalToggles) ); - if (profileApi.success) { - syncPersonalTogglesFromProfile(); + if (memberEmailNotificationsApi.success && result?.data) { + applyPersonalToggleState(result.data); snackbar.success(t.get('settings.notifications.personal.save_success')); } @@ -164,7 +185,7 @@ {$t('settings.notifications.personal.heading')} - {$t('settings.notifications.personal.description')} + {personalDescription}
@@ -182,7 +203,7 @@
{#each section.keys as key (key)} - +
{$t(`settings.notifications.toggles.${key}.label`)} @@ -200,7 +221,7 @@