chore(release): student course progress, email preference table, token auth docs & lesson lock fix (#762)
This commit is contained in:
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -52,6 +52,7 @@ import { TOrganization } from '@db/types';
|
|||||||
import { assetsRouter } from '@api/routes/organization/assets';
|
import { assetsRouter } from '@api/routes/organization/assets';
|
||||||
import { autoJoinOrg } from '@api/services/organization/auto-join';
|
import { autoJoinOrg } from '@api/services/organization/auto-join';
|
||||||
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
||||||
|
import { organizationMemberEmailNotificationsRouter } from '@api/routes/organization/member-email-notifications';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||||
@@ -768,4 +769,5 @@ export const organizationRouter = new Hono()
|
|||||||
.route('/widgets', widgetsRouter)
|
.route('/widgets', widgetsRouter)
|
||||||
.route('/assets', assetsRouter)
|
.route('/assets', assetsRouter)
|
||||||
.route('/ai-tutor', organizationAiTutorRouter)
|
.route('/ai-tutor', organizationAiTutorRouter)
|
||||||
|
.route('/member/email-notifications', organizationMemberEmailNotificationsRouter)
|
||||||
.route('/:orgId/quiz', quizRouter);
|
.route('/:orgId/quiz', quizRouter);
|
||||||
|
|||||||
@@ -100,16 +100,7 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
|
|||||||
*/
|
*/
|
||||||
export async function updateUser(userId: string, data: TUpdateProfile) {
|
export async function updateUser(userId: string, data: TUpdateProfile) {
|
||||||
try {
|
try {
|
||||||
const { emailNotifications, ...profileFields } = data;
|
const updatedProfile = await updateProfile(userId, data);
|
||||||
const updatePayload: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>> = {
|
|
||||||
...profileFields
|
|
||||||
};
|
|
||||||
|
|
||||||
if (emailNotifications !== undefined) {
|
|
||||||
updatePayload.settings = { emailNotifications };
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedProfile = await updateProfile(userId, updatePayload);
|
|
||||||
|
|
||||||
if (!updatedProfile) {
|
if (!updatedProfile) {
|
||||||
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
|
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,30 +137,6 @@ export class ProfileApi extends BaseApiWithErrors {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEmailNotifications(emailNotifications: Record<string, boolean>) {
|
|
||||||
try {
|
|
||||||
this.isLoading = true;
|
|
||||||
this.errors = {};
|
|
||||||
this.success = false;
|
|
||||||
|
|
||||||
await this.execute<typeof classroomio.account.profile.$put>({
|
|
||||||
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) {
|
async changeEmail(fields: TChangeEmailForm) {
|
||||||
// Validate email format
|
// Validate email format
|
||||||
const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail });
|
const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import CourseProgressPopover from './course-progress-popover.svelte';
|
||||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||||
import CoursePublicBadge from './course-public-badge.svelte';
|
import CoursePublicBadge from './course-public-badge.svelte';
|
||||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||||
@@ -85,6 +86,10 @@
|
|||||||
|
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
|
|
||||||
|
{#if $isStudentExperience}
|
||||||
|
<CourseProgressPopover class="md:hidden" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={toggleAiAssistant}
|
onclick={toggleAiAssistant}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Progress } from '@cio/ui/base/progress';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import type { ContentProgress } from '$features/course/utils/content';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
progress: ContentProgress;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { progress, class: className = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-xs font-medium">{$t('course.sidebar.progress.title')}</span>
|
||||||
|
<span class="ui:text-primary text-xs font-semibold tabular-nums">{progress.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress.percent} max={100} class="ui:h-1.5 mt-2" />
|
||||||
|
<p class="ui:text-muted-foreground mt-2 truncate text-[11px]">
|
||||||
|
{$t('course.sidebar.progress.lessons', {
|
||||||
|
completed: progress.lessonsComplete,
|
||||||
|
total: progress.lessonsTotal
|
||||||
|
})}{#if progress.exercisesTotal > 0}
|
||||||
|
· {$t('course.sidebar.progress.exercises', {
|
||||||
|
completed: progress.exercisesComplete,
|
||||||
|
total: progress.exercisesTotal
|
||||||
|
})}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Popover from '@cio/ui/base/popover';
|
||||||
|
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
|
||||||
|
import { courseApi } from '$features/course/api';
|
||||||
|
import { getCourseProgress } from '$features/course/utils/content';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import CourseProgressCard from './course-progress-card.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className = '' }: Props = $props();
|
||||||
|
|
||||||
|
const progress = $derived(getCourseProgress(courseApi.course));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if progress.total > 0}
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger class="shrink-0 {className}" aria-label={$t('course.sidebar.progress.title')}>
|
||||||
|
<PercentRingProgress value={progress.percent} size="small" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-72" align="end">
|
||||||
|
<CourseProgressCard {progress} />
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
{/if}
|
||||||
+5
-3
@@ -8,6 +8,7 @@
|
|||||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||||
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
|
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
|
||||||
import { isOrgStudent } from '$lib/utils/store/app';
|
import { isOrgStudent } from '$lib/utils/store/app';
|
||||||
|
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courseApi, lessonApi } from '$features/course/api';
|
import { courseApi, lessonApi } from '$features/course/api';
|
||||||
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
|
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
|
||||||
@@ -69,8 +70,10 @@
|
|||||||
const showMarkComplete = $derived(!!lessonId && !exerciseId);
|
const showMarkComplete = $derived(!!lessonId && !exerciseId);
|
||||||
|
|
||||||
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
|
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
|
||||||
const isTeacherLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
|
const contentLockReason = $derived(
|
||||||
const isProgressionLocked = $derived($isOrgStudent && currentLessonItem?.accessible === false && !isTeacherLocked);
|
lessonId ? getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson) : null
|
||||||
|
);
|
||||||
|
const isLessonLocked = $derived($isOrgStudent === true && contentLockReason !== null);
|
||||||
const isVideoWatchLesson = $derived.by(() => {
|
const isVideoWatchLesson = $derived.by(() => {
|
||||||
if (!lessonId) return false;
|
if (!lessonId) return false;
|
||||||
|
|
||||||
@@ -98,7 +101,6 @@
|
|||||||
|
|
||||||
const watchVideosRequired = $derived(lessonApi.lesson?.watchProgress?.videosRequired ?? 0);
|
const watchVideosRequired = $derived(lessonApi.lesson?.watchProgress?.videosRequired ?? 0);
|
||||||
const watchVideosComplete = $derived(lessonApi.lesson?.watchProgress?.videosComplete ?? 0);
|
const watchVideosComplete = $derived(lessonApi.lesson?.watchProgress?.videosComplete ?? 0);
|
||||||
const isLessonLocked = $derived(isTeacherLocked || isProgressionLocked);
|
|
||||||
const isVideoWatchComplete = $derived(isVideoWatchLesson && (lessonApi.lesson?.watchProgress?.isComplete ?? false));
|
const isVideoWatchComplete = $derived(isVideoWatchLesson && (lessonApi.lesson?.watchProgress?.isComplete ?? false));
|
||||||
const showVideoWatchCompleteState = $derived(isVideoWatchComplete || (isVideoWatchLesson && isLessonComplete));
|
const showVideoWatchCompleteState = $derived(isVideoWatchComplete || (isVideoWatchLesson && isLessonComplete));
|
||||||
const isMarkCompleteDisabled = $derived(
|
const isMarkCompleteDisabled = $derived(
|
||||||
|
|||||||
+18
-3
@@ -8,12 +8,13 @@
|
|||||||
import * as Collapsible from '@cio/ui/base/collapsible';
|
import * as Collapsible from '@cio/ui/base/collapsible';
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { getContentRoute, getCourseContent } from '$features/course/utils/content';
|
import { getContentItemsProgress, getContentRoute, getCourseContent } from '$features/course/utils/content';
|
||||||
import { CircleCheckIcon } from '$features/ui/icons';
|
import { CircleCheckIcon } from '$features/ui/icons';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { RadioIcon } from '@cio/ui/custom/moving-icons';
|
import { RadioIcon } from '@cio/ui/custom/moving-icons';
|
||||||
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
||||||
|
import { isOrgStudent } from '$lib/utils/store/app';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -54,6 +55,18 @@
|
|||||||
<span class="flex-1 truncate">{section.title}</span>
|
<span class="flex-1 truncate">{section.title}</span>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
|
{#if isStudent}
|
||||||
|
{@const sectionProgress = getContentItemsProgress(section.items)}
|
||||||
|
{#if sectionProgress.total > 0}
|
||||||
|
<span
|
||||||
|
class="shrink-0 text-[11px] font-medium tabular-nums {sectionProgress.percent === 100
|
||||||
|
? 'ui:text-green-600'
|
||||||
|
: 'ui:text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{sectionProgress.percent}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{#if canOpenContentModal}
|
{#if canOpenContentModal}
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost-outline"
|
variant="ghost-outline"
|
||||||
@@ -85,7 +98,8 @@
|
|||||||
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
||||||
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
|
{@const isLockedForStudent =
|
||||||
|
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
|
||||||
<a
|
<a
|
||||||
href={resolve(getContentRoute(id, contentItem), {})}
|
href={resolve(getContentRoute(id, contentItem), {})}
|
||||||
aria-disabled={isLockedForStudent}
|
aria-disabled={isLockedForStudent}
|
||||||
@@ -137,7 +151,8 @@
|
|||||||
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
||||||
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
|
{@const isLockedForStudent =
|
||||||
|
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
|
||||||
<a
|
<a
|
||||||
href={resolve(getContentRoute(id, contentItem), {})}
|
href={resolve(getContentRoute(id, contentItem), {})}
|
||||||
aria-disabled={isLockedForStudent}
|
aria-disabled={isLockedForStudent}
|
||||||
|
|||||||
+30
-2
@@ -2,25 +2,53 @@
|
|||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||||
|
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { shortenName } from '$lib/utils/functions/string';
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { getNavItemRoute } from '$features/course/utils/functions';
|
import { getNavItemRoute } from '$features/course/utils/functions';
|
||||||
|
import { getCourseProgress } from '$features/course/utils/content';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isStudent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isStudent = false }: Props = $props();
|
||||||
|
|
||||||
|
const sidebar = Sidebar.useSidebar();
|
||||||
const courseTitle = $derived(courseApi.course?.title);
|
const courseTitle = $derived(courseApi.course?.title);
|
||||||
const courseId = $derived(courseApi.course?.id);
|
const courseId = $derived(courseApi.course?.id);
|
||||||
|
const progress = $derived(getCourseProgress(courseApi.course));
|
||||||
|
const isSidebarExpanded = $derived(sidebar.open || sidebar.isMobile);
|
||||||
|
const showProgressRing = $derived(isStudent && progress.total > 0 && isSidebarExpanded);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton
|
<Sidebar.MenuButton
|
||||||
size="sm"
|
size={showProgressRing ? 'lg' : 'sm'}
|
||||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href={courseId ? resolve(getNavItemRoute(courseId), {}) : '#'} {...props}>
|
<a href={courseId ? resolve(getNavItemRoute(courseId), {}) : '#'} {...props}>
|
||||||
{#if $currentOrg.name}
|
{#if showProgressRing}
|
||||||
|
<PercentRingProgress value={progress.percent} size="small" />
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
{#if courseTitle}
|
||||||
|
<span class="truncate text-sm font-medium">{courseTitle}</span>
|
||||||
|
{:else}
|
||||||
|
<Skeleton class="h-4 w-24" />
|
||||||
|
{/if}
|
||||||
|
<span class="ui:text-muted-foreground truncate text-xs">
|
||||||
|
{$t('course.sidebar.progress.completed', {
|
||||||
|
completed: progress.completed,
|
||||||
|
total: progress.total
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else if $currentOrg.name}
|
||||||
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
|
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
|
||||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||||
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
|
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||||
import PoweredBy from '$features/ui/powered-by.svelte';
|
import PoweredBy from '$features/ui/powered-by.svelte';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
|
import { getCourseProgress } from '$features/course/utils/content';
|
||||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||||
|
import CourseProgressCard from '$features/course/components/course-progress-card.svelte';
|
||||||
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
||||||
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
|
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
|
||||||
|
|
||||||
@@ -45,6 +47,10 @@
|
|||||||
let stopSidebarResize: (() => void) | null = null;
|
let stopSidebarResize: (() => void) | null = null;
|
||||||
|
|
||||||
const attributionCourseSlug = $derived(courseApi.course?.slug ?? null);
|
const attributionCourseSlug = $derived(courseApi.course?.slug ?? null);
|
||||||
|
const courseProgress = $derived(getCourseProgress(courseApi.course));
|
||||||
|
const showCourseProgress = $derived(
|
||||||
|
$isStudentExperience && isCourseReady && sidebar.open && !sidebar.isMobile && courseProgress.total > 0
|
||||||
|
);
|
||||||
|
|
||||||
function clampSidebarWidth(width: number) {
|
function clampSidebarWidth(width: number) {
|
||||||
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
||||||
@@ -114,7 +120,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Sidebar.Root collapsible="icon" class="z-app-bar-elevated!" mobileOverlayClass="z-app-bar-elevated!">
|
<Sidebar.Root collapsible="icon" class="z-app-bar-elevated!" mobileOverlayClass="z-app-bar-elevated!">
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<CourseSidebarLogo />
|
<CourseSidebarLogo isStudent={$isStudentExperience} />
|
||||||
</Sidebar.Header>
|
</Sidebar.Header>
|
||||||
|
|
||||||
<Sidebar.Content>
|
<Sidebar.Content>
|
||||||
@@ -143,6 +149,12 @@
|
|||||||
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
|
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
|
||||||
|
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
|
{#if showCourseProgress}
|
||||||
|
<CourseProgressCard
|
||||||
|
progress={courseProgress}
|
||||||
|
class="ui:border-sidebar-border ui:bg-sidebar-accent/50 rounded-lg border p-3"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<PoweredBy
|
<PoweredBy
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
sidebarUtmSource="lms-course-sidebar"
|
sidebarUtmSource="lms-course-sidebar"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import MODES from '$lib/utils/constants/mode';
|
import MODES from '$lib/utils/constants/mode';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { isOrgStudent, isStudentExperience } from '$lib/utils/store/app';
|
import { isOrgStudent, isStudentExperience } from '$lib/utils/store/app';
|
||||||
|
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
|
import { Spinner } from '@cio/ui/base/spinner';
|
||||||
import { RoleBasedSecurity } from '$features/ui';
|
import { RoleBasedSecurity } from '$features/ui';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -83,17 +85,31 @@
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const lessonTitle = $derived(currentLessonContentItem?.title || lessonApi.lesson?.title || 'Lesson');
|
const lessonTitle = $derived(currentLessonContentItem?.title || lessonApi.lesson?.title || 'Lesson');
|
||||||
const isTeacherLocked = $derived.by(() => {
|
const contentLockReason = $derived(getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson));
|
||||||
if ($isOrgStudent && currentLessonContentItem) {
|
const isLessonLockedForStudent = $derived($isOrgStudent === true && contentLockReason !== null);
|
||||||
return (currentLessonContentItem.isUnlocked ?? false) === false;
|
const isStudentLessonStateReady = $derived.by(() => {
|
||||||
|
if ($isOrgStudent !== true) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (lessonApi.lesson?.isUnlocked ?? false) === false;
|
if (courseApi.course?.id !== courseId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLockReason !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLessonContentItem) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lessonApi.lesson?.id === lessonId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return courseApi.course?.content != null;
|
||||||
});
|
});
|
||||||
const isProgressionLocked = $derived(
|
|
||||||
$isOrgStudent && currentLessonContentItem?.accessible === false && !isTeacherLocked
|
|
||||||
);
|
|
||||||
const isLessonUnlocked = $derived(!isTeacherLocked && !isProgressionLocked);
|
|
||||||
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
|
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
|
||||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||||
const isLiveSessionLesson = $derived(Boolean(lessonApi.lesson?.callUrl && lessonApi.lesson?.lessonAt));
|
const isLiveSessionLesson = $derived(Boolean(lessonApi.lesson?.callUrl && lessonApi.lesson?.lessonAt));
|
||||||
@@ -450,7 +466,7 @@
|
|||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<div class={`overflow-x-hidden pb-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
|
<div class={`overflow-x-hidden pb-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
|
||||||
{#if isLiveSessionLesson && mode === MODES.view && !($isOrgStudent && !isLessonUnlocked)}
|
{#if isLiveSessionLesson && mode === MODES.view && !isLessonLockedForStudent}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<LiveSessionCard
|
<LiveSessionCard
|
||||||
title={lessonTitle}
|
title={lessonTitle}
|
||||||
@@ -461,11 +477,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $isOrgStudent && !isLessonUnlocked}
|
{#if $isOrgStudent === true && !isStudentLessonStateReady}
|
||||||
<StudentContentLockedNotice
|
<div class="flex justify-center py-16">
|
||||||
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
<Spinner />
|
||||||
contentType={ContentType.Lesson}
|
</div>
|
||||||
/>
|
{:else if isLessonLockedForStudent && contentLockReason}
|
||||||
|
<StudentContentLockedNotice reason={contentLockReason} contentType={ContentType.Lesson} />
|
||||||
{:else if mode === MODES.edit}
|
{:else if mode === MODES.edit}
|
||||||
<UnderlineTabs.Root
|
<UnderlineTabs.Root
|
||||||
bind:value={currentTabValue}
|
bind:value={currentTabValue}
|
||||||
@@ -541,6 +558,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
|
{:else if $isOrgStudent === true}
|
||||||
|
<Empty
|
||||||
|
title={$t('course.navItem.lessons.materials.no_content')}
|
||||||
|
icon={VideoIcon}
|
||||||
|
variant="page"
|
||||||
|
class="text-center"
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Empty
|
<Empty
|
||||||
title={$t('course.navItem.lessons.materials.body_heading')}
|
title={$t('course.navItem.lessons.materials.body_heading')}
|
||||||
@@ -554,11 +578,9 @@
|
|||||||
variant="page"
|
variant="page"
|
||||||
class="text-center"
|
class="text-center"
|
||||||
>
|
>
|
||||||
{#if !$isOrgStudent}
|
<Button onclick={toggleMode}>
|
||||||
<Button onclick={toggleMode}>
|
{$t('course.navItem.lessons.materials.get_started')}
|
||||||
{$t('course.navItem.lessons.materials.get_started')}
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Empty>
|
</Empty>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,42 @@ export function getCourseContent(course: Course | null) {
|
|||||||
|
|
||||||
const NAVIGABLE_CONTENT_TYPES = [CourseContentType.Lesson, CourseContentType.Exercise] as const;
|
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[] {
|
export function getOrderedNavigableContent(course: Course | null): ContentItem[] {
|
||||||
const content = getCourseContent(course);
|
const content = getCourseContent(course);
|
||||||
const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items;
|
const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items;
|
||||||
|
|||||||
@@ -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<GetMemberEmailNotificationsRequest>({
|
||||||
|
requestFn: () => classroomio.organization.member['email-notifications'].$get(),
|
||||||
|
logContext: 'fetching member email notification preferences'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(preferences: Record<string, boolean>) {
|
||||||
|
return this.execute<UpdateMemberEmailNotificationsRequest>({
|
||||||
|
requestFn: () => classroomio.organization.member['email-notifications'].$put({ json: preferences }),
|
||||||
|
logContext: 'updating member email notification preferences'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memberEmailNotificationsApi = new MemberEmailNotificationsApi();
|
||||||
@@ -6,12 +6,11 @@
|
|||||||
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS,
|
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS,
|
||||||
TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS
|
TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS
|
||||||
} from '@cio/utils/notifications';
|
} from '@cio/utils/notifications';
|
||||||
import { profile } from '$lib/utils/store/user';
|
|
||||||
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||||
import { isOrgStudent } from '$lib/utils/store/app';
|
import { isOrgStudent } from '$lib/utils/store/app';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
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 { orgApi } from '$features/org/api/org.svelte';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
@@ -26,10 +25,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let personalToggles = $state(buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS));
|
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 orgToggles = $state(buildToggleState(EMAIL_NOTIFICATION_TOGGLE_KEYS));
|
||||||
let isSavingPersonal = $state(false);
|
let isSavingPersonal = $state(false);
|
||||||
let isSavingOrg = $state(false);
|
let isSavingOrg = $state(false);
|
||||||
let hasHydratedFromStores = $state(false);
|
let isLoadingPersonal = $state(false);
|
||||||
|
let hasHydratedOrgToggles = $state(false);
|
||||||
|
|
||||||
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
|
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
|
||||||
|
|
||||||
@@ -42,12 +43,18 @@
|
|||||||
: PERSONAL_EMAIL_NOTIFICATION_SECTIONS
|
: PERSONAL_EMAIL_NOTIFICATION_SECTIONS
|
||||||
);
|
);
|
||||||
|
|
||||||
function syncPersonalTogglesFromProfile() {
|
const personalDescription = $derived(
|
||||||
const profileState = get(profile);
|
$currentOrg?.name
|
||||||
const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, profileState.settings?.emailNotifications);
|
? t.get('settings.notifications.personal.description_with_org', { orgName: $currentOrg.name })
|
||||||
|
: t.get('settings.notifications.personal.description')
|
||||||
|
);
|
||||||
|
|
||||||
|
function applyPersonalToggleState(source?: Partial<Record<string, boolean>> | null) {
|
||||||
|
const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, source);
|
||||||
|
|
||||||
for (const key of PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS) {
|
for (const key of PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS) {
|
||||||
personalToggles[key] = next[key];
|
personalToggles[key] = next[key];
|
||||||
|
savedPersonalToggles[key] = next[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,41 +70,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryHydrateFromStores() {
|
async function loadPersonalPreferences() {
|
||||||
if (hasHydratedFromStores) return;
|
if (!$currentOrg?.id) return;
|
||||||
|
|
||||||
const profileState = get(profile);
|
isLoadingPersonal = true;
|
||||||
if (!profileState.id) return;
|
|
||||||
|
|
||||||
syncPersonalTogglesFromProfile();
|
const result = await memberEmailNotificationsApi.fetch();
|
||||||
|
|
||||||
if (get(isOrgAdmin)) {
|
if (result?.data) {
|
||||||
if (!get(currentOrg)?.id) return;
|
applyPersonalToggleState(result.data);
|
||||||
syncOrgTogglesFromCurrentOrg();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasHydratedFromStores = true;
|
isLoadingPersonal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryHydrateOrgToggles() {
|
||||||
|
if (hasHydratedOrgToggles || !get(isOrgAdmin)) return;
|
||||||
|
|
||||||
|
if (!get(currentOrg)?.id) return;
|
||||||
|
|
||||||
|
syncOrgTogglesFromCurrentOrg();
|
||||||
|
hasHydratedOrgToggles = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
tryHydrateFromStores();
|
tryHydrateOrgToggles();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (hasHydratedFromStores) return;
|
const orgId = $currentOrg?.id;
|
||||||
|
|
||||||
const profileId = $profile.id;
|
if (!orgId) return;
|
||||||
const orgId = $isOrgAdmin ? $currentOrg?.id : 'ready';
|
|
||||||
|
|
||||||
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(
|
const personalHasChanges = $derived(
|
||||||
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some(
|
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some((key) => personalToggles[key] !== savedPersonalToggles[key])
|
||||||
(key) => personalToggles[key] !== ($profile.settings?.emailNotifications?.[key] !== false)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const orgHasChanges = $derived(
|
const orgHasChanges = $derived(
|
||||||
@@ -117,14 +136,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSavePersonal() {
|
async function handleSavePersonal() {
|
||||||
|
if (!$currentOrg?.id) return;
|
||||||
|
|
||||||
isSavingPersonal = true;
|
isSavingPersonal = true;
|
||||||
|
|
||||||
await profileApi.updateEmailNotifications(
|
const result = await memberEmailNotificationsApi.update(
|
||||||
toStoredPreferences(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, personalToggles)
|
toStoredPreferences(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, personalToggles)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (profileApi.success) {
|
if (memberEmailNotificationsApi.success && result?.data) {
|
||||||
syncPersonalTogglesFromProfile();
|
applyPersonalToggleState(result.data);
|
||||||
snackbar.success(t.get('settings.notifications.personal.save_success'));
|
snackbar.success(t.get('settings.notifications.personal.save_success'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +185,7 @@
|
|||||||
{$t('settings.notifications.personal.heading')}
|
{$t('settings.notifications.personal.heading')}
|
||||||
</Field.Legend>
|
</Field.Legend>
|
||||||
<Field.Description>
|
<Field.Description>
|
||||||
{$t('settings.notifications.personal.description')}
|
{personalDescription}
|
||||||
</Field.Description>
|
</Field.Description>
|
||||||
|
|
||||||
<div class="mt-4 space-y-6">
|
<div class="mt-4 space-y-6">
|
||||||
@@ -182,7 +203,7 @@
|
|||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
{#each section.keys as key (key)}
|
{#each section.keys as key (key)}
|
||||||
<Field.Field orientation="horizontal">
|
<Field.Field orientation="horizontal">
|
||||||
<Switch bind:checked={personalToggles[key]} disabled={isSavingPersonal} />
|
<Switch bind:checked={personalToggles[key]} disabled={isSavingPersonal || isLoadingPersonal} />
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<Field.Label>{$t(`settings.notifications.toggles.${key}.label`)}</Field.Label>
|
<Field.Label>{$t(`settings.notifications.toggles.${key}.label`)}</Field.Label>
|
||||||
<Field.Description>
|
<Field.Description>
|
||||||
@@ -200,7 +221,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
loading={isSavingPersonal}
|
loading={isSavingPersonal}
|
||||||
disabled={!personalHasChanges || isSavingPersonal}
|
disabled={!personalHasChanges || isSavingPersonal || isLoadingPersonal || !$currentOrg?.id}
|
||||||
onclick={handleSavePersonal}
|
onclick={handleSavePersonal}
|
||||||
>
|
>
|
||||||
{$t('settings.notifications.personal.save')}
|
{$t('settings.notifications.personal.save')}
|
||||||
|
|||||||
@@ -16,3 +16,14 @@ export type AiUsageData = GetUsageSuccess['data'];
|
|||||||
export type PurchasedSummaryData = GetPurchasedSuccess['data'];
|
export type PurchasedSummaryData = GetPurchasedSuccess['data'];
|
||||||
export type LeaderboardData = GetLeaderboardSuccess['data'];
|
export type LeaderboardData = GetLeaderboardSuccess['data'];
|
||||||
export type LeaderboardEntry = LeaderboardData['entries'][number];
|
export type LeaderboardEntry = LeaderboardData['entries'][number];
|
||||||
|
|
||||||
|
export type GetMemberEmailNotificationsRequest =
|
||||||
|
(typeof classroomio.organization.member)['email-notifications']['$get'];
|
||||||
|
export type UpdateMemberEmailNotificationsRequest =
|
||||||
|
(typeof classroomio.organization.member)['email-notifications']['$put'];
|
||||||
|
|
||||||
|
export type GetMemberEmailNotificationsSuccess = Extract<
|
||||||
|
InferResponseType<GetMemberEmailNotificationsRequest>,
|
||||||
|
{ success: true }
|
||||||
|
>;
|
||||||
|
export type MemberEmailNotificationPreferences = GetMemberEmailNotificationsSuccess['data'];
|
||||||
|
|||||||
@@ -116,10 +116,24 @@ export const baseNavConfig: NavItemConfig[] = [
|
|||||||
titleKey: 'Profile',
|
titleKey: 'Profile',
|
||||||
path: '/settings'
|
path: '/settings'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'Notifications',
|
||||||
|
path: '/settings/notifications'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
titleKey: 'Integrations',
|
titleKey: 'Integrations',
|
||||||
path: '/settings/integrations'
|
path: '/settings/integrations'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
nestedRoutes: [
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
titleKey: 'settings.tabs.notifications_tab'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'integrations',
|
||||||
|
titleKey: 'settings.tabs.integrations_tab'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Overholdelse",
|
"title": "Overholdelse",
|
||||||
"due_date": "Forfalder",
|
"due_date": "Forfalder",
|
||||||
"valid_until": "Gælder indtil kl"
|
"valid_until": "Gælder indtil kl"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Dine fremskridt",
|
||||||
|
"completed": "{completed} af {total} fuldført",
|
||||||
|
"lessons": "{completed} af {total} lektioner",
|
||||||
|
"exercises": "{completed} af {total} øvelser"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "Hold",
|
"teams_tab": "Hold",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "AI underviser",
|
"ai_tutor_tab": "AI underviser",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "Notifikationer"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "Åbent Akademi"
|
"view_site": "Åbent Akademi"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Notifikationer",
|
||||||
"page_title": "Notifications",
|
"page_title": "Notifikationer",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Vælg hvilke e-mailnotifikationer du modtager, og som administrator hvilke e-mails din organisation sender.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Kursusafslutning og certifikater",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "Send e-mail til studerende, når de gennemfører et kursus og opnår et certifikat."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "Velkomst-e-mails ved tilmelding",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Send velkomst-e-mails, når personalet tilmelder studerende til kurser eller kohorter."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Påmindelser og opdateringer om livesessioner",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "Send e-mail til studerende før livesessioner og når sessionsdetaljer ændres."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Påmindelser om kohortemål",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "Send e-mail til studerende om kommende eller overskredne kohortlæringsmål."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Øvelse tildelt",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "Send e-mail til studerende, når en øvelse tildeles eller påmindes."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Kursusmeddelelser og kommentarer",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "Send e-mail til kursusmedlemmer om nye opslag og kommentarer på nyhedsfeedet."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Bedømmelsesresultater",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "Send e-mail til studerende, når deres aflevering er bedømt eller opdateret."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Nye afleveringer til bedømmelse",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "Send e-mail til undervisere, når en studerende afleverer arbejde, der skal bedømmes."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Ny studerende tilmelder sig",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "Send e-mail til undervisere, når en studerende tilmelder sig et kursus."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Organisationsstandarder",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Når deaktiveret her, modtager ingen i din organisation den e-mail. Medlemmer kan stadig fravælge e-mails, du lader være aktiveret.",
|
||||||
"save": "Save organization defaults",
|
"save": "Gem organisationsstandarder",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Organisationsnotifikationsstandarder gemt"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Dine e-mailpræferencer",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Gem personlige præferencer",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Personlige notifikationspræferencer gemt",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Disse gælder for e-mails sendt til dig i denne organisation. Din organisation kan stadig deaktivere visse e-mails for alle.",
|
||||||
|
"description_with_org": "Disse gælder for e-mails sendt til dig i {orgName}. Din organisation kan stadig deaktivere visse e-mails for alle."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Tilmelding",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "Velkomst-e-mails sendt, når personalet tilføjer medlemmer til kurser eller kohorter."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Kohorte",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Påmindelser om kohortens læringsmål og deadlines."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Kursus",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Tilmeldinger, afleveringer, lektioner, meddelelser og gennemførelser i dine kurser."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Vælg hvilke e-mailnotifikationer du modtager fra ClassroomIO."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Compliance",
|
"title": "Compliance",
|
||||||
"due_date": "Fällig",
|
"due_date": "Fällig",
|
||||||
"valid_until": "Gültig bis"
|
"valid_until": "Gültig bis"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Ihr Fortschritt",
|
||||||
|
"completed": "{completed} von {total} abgeschlossen",
|
||||||
|
"lessons": "{completed} von {total} Lektionen",
|
||||||
|
"exercises": "{completed} von {total} Übungen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "Mannschaften",
|
"teams_tab": "Mannschaften",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "KI-Tutor",
|
"ai_tutor_tab": "KI-Tutor",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "Benachrichtigungen"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "Offene Akademie"
|
"view_site": "Offene Akademie"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Benachrichtigungen",
|
||||||
"page_title": "Notifications",
|
"page_title": "Benachrichtigungen",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Wählen Sie, welche E-Mail-Benachrichtigungen Sie erhalten, und als Administrator, welche E-Mails Ihre Organisation sendet.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Kursabschluss und Zertifikate",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "E-Mail an Studenten senden, wenn sie einen Kurs abschließen und ein Zertifikat erhalten."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "Willkommens-E-Mails bei Anmeldung",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Willkommens-E-Mails senden, wenn Mitarbeitende Studenten zu Kursen oder Kohorten anmelden."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Erinnerungen und Updates zu Live-Sitzungen",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "E-Mail an Studenten vor Live-Sitzungen und bei Änderungen der Sitzungsdetails senden."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Erinnerungen an Kohortenlernziele",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "E-Mail an Studenten über bevorstehende oder überfällige Kohortenlernziele senden."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Übung zugewiesen",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "E-Mail an Studenten senden, wenn eine Übung zugewiesen oder erinnert wird."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Kursankündigungen und Kommentare",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "E-Mail an Kursmitglieder über neue Beiträge und Kommentare im Newsfeed senden."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Bewertungsergebnisse",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "E-Mail an Studenten senden, wenn ihre Abgabe bewertet oder aktualisiert wird."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Neue Abgaben zum Bewerten",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "E-Mail an Tutoren senden, wenn ein Student Arbeit zur Bewertung einreicht."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Neuer Student meldet sich an",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "E-Mail an Tutoren senden, wenn sich ein Student für einen Kurs anmeldet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Organisationsstandards",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Wenn hier deaktiviert, erhält niemand in Ihrer Organisation diese E-Mail. Mitglieder können sich weiterhin von E-Mails abmelden, die Sie aktiviert lassen.",
|
||||||
"save": "Save organization defaults",
|
"save": "Organisationsstandards speichern",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Organisations-Benachrichtigungsstandards gespeichert"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Ihre E-Mail-Einstellungen",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Persönliche Einstellungen speichern",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Persönliche Benachrichtigungseinstellungen gespeichert",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Diese Einstellungen gelten für E-Mails, die Ihnen in dieser Organisation gesendet werden. Ihre Organisation kann bestimmte E-Mails für alle deaktivieren.",
|
||||||
|
"description_with_org": "Diese Einstellungen gelten für E-Mails, die Ihnen in {orgName} gesendet werden. Ihre Organisation kann bestimmte E-Mails für alle deaktivieren."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Anmeldung",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "Willkommens-E-Mails, die gesendet werden, wenn Mitarbeitende Mitglieder zu Kursen oder Kohorten hinzufügen."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Kohorte",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Erinnerungen an Lernziele und Fristen der Kohorte."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Kurs",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Anmeldungen, Abgaben, Lektionen, Ankündigungen und Abschlüsse in Ihren Kursen."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Wählen Sie, welche E-Mail-Benachrichtigungen Sie von ClassroomIO erhalten."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -440,9 +440,11 @@
|
|||||||
"heading": "Notifications",
|
"heading": "Notifications",
|
||||||
"page_title": "Notifications",
|
"page_title": "Notifications",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||||
|
"personal_page_subtitle": "Choose which email notifications you receive from ClassroomIO.",
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Your email preferences",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"description": "These apply to emails sent to you in this organization. Your organization may still disable certain emails for everyone.",
|
||||||
|
"description_with_org": "These apply to emails sent to you in {orgName}. Your organization may still disable certain emails for everyone.",
|
||||||
"save": "Save personal preferences",
|
"save": "Save personal preferences",
|
||||||
"save_success": "Personal notification preferences saved"
|
"save_success": "Personal notification preferences saved"
|
||||||
},
|
},
|
||||||
@@ -2003,6 +2005,12 @@
|
|||||||
"content_count": {
|
"content_count": {
|
||||||
"lessons": "{count} lessons",
|
"lessons": "{count} lessons",
|
||||||
"exercises": "{count} exercises"
|
"exercises": "{count} exercises"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Your progress",
|
||||||
|
"completed": "{completed} of {total} completed",
|
||||||
|
"lessons": "{completed} of {total} lessons",
|
||||||
|
"exercises": "{completed} of {total} exercises"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Cumplimiento",
|
"title": "Cumplimiento",
|
||||||
"due_date": "debido",
|
"due_date": "debido",
|
||||||
"valid_until": "Válido hasta"
|
"valid_until": "Válido hasta"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Tu progreso",
|
||||||
|
"completed": "{completed} de {total} completados",
|
||||||
|
"lessons": "{completed} de {total} lecciones",
|
||||||
|
"exercises": "{completed} de {total} ejercicios"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "equipos",
|
"teams_tab": "equipos",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "Tutor de IA",
|
"ai_tutor_tab": "Tutor de IA",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "Notificaciones"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "Academia Abierta"
|
"view_site": "Academia Abierta"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Notificaciones",
|
||||||
"page_title": "Notifications",
|
"page_title": "Notificaciones",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Elige qué notificaciones por correo recibes y, como administrador, qué correos envía tu organización.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Finalización del curso y certificados",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "Enviar correo a estudiantes cuando completan un curso y obtienen un certificado."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "Correos de bienvenida por inscripción",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Enviar correos de bienvenida cuando el personal inscribe estudiantes en cursos o cohortes."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Recordatorios y actualizaciones de sesiones en vivo",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "Enviar correo a estudiantes antes de sesiones en vivo y cuando cambian los detalles."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Recordatorios de objetivos de cohorte",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "Enviar correo a estudiantes sobre objetivos de aprendizaje próximos o vencidos."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Ejercicio asignado",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "Enviar correo a estudiantes cuando se asigna o recuerda un ejercicio."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Anuncios y comentarios del curso",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "Enviar correo a los miembros del curso sobre nuevas publicaciones y comentarios en el feed."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Resultados de calificación",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "Enviar correo a estudiantes cuando su entrega es calificada o actualizada."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Nuevas entregas para calificar",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "Enviar correo a tutores cuando un estudiante entrega trabajo que necesita calificación."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Nuevo estudiante se inscribe",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "Enviar correo a tutores cuando un estudiante se inscribe en un curso."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Valores predeterminados de la organización",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Si se desactiva aquí, nadie en tu organización recibirá ese correo. Los miembros aún pueden darse de baja de los correos que dejes habilitados.",
|
||||||
"save": "Save organization defaults",
|
"save": "Guardar valores predeterminados de la organización",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Valores predeterminados de notificación de la organización guardados"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Tus preferencias de correo",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Guardar preferencias personales",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Preferencias de notificación personales guardadas",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Se aplican a los correos electrónicos que recibes en esta organización. Tu organización aún puede desactivar ciertos correos para todos.",
|
||||||
|
"description_with_org": "Se aplican a los correos electrónicos que recibes en {orgName}. Tu organización aún puede desactivar ciertos correos para todos."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Inscripción",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "Correos de bienvenida enviados cuando el personal añade miembros a cursos o cohortes."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Cohorte",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Recordatorios sobre objetivos de aprendizaje y plazos de la cohorte."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Curso",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Inscripciones, entregas, lecciones, anuncios y finalizaciones en tus cursos."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Elige qué notificaciones por correo recibes de ClassroomIO."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Conformité",
|
"title": "Conformité",
|
||||||
"due_date": "Due",
|
"due_date": "Due",
|
||||||
"valid_until": "Valable jusqu'au"
|
"valid_until": "Valable jusqu'au"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Votre progression",
|
||||||
|
"completed": "{completed} sur {total} terminés",
|
||||||
|
"lessons": "{completed} sur {total} leçons",
|
||||||
|
"exercises": "{completed} sur {total} exercices"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3641,71 +3647,73 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Notifications",
|
||||||
"page_title": "Notifications",
|
"page_title": "Notifications",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Choisissez les notifications par e-mail que vous recevez et, en tant qu’administrateur, celles que votre organisation envoie.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Achèvement du cours et certificats",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "Envoyer un e-mail aux étudiants lorsqu'ils terminent un cours et obtiennent un certificat."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "E-mails de bienvenue d'inscription",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Envoyer des e-mails de bienvenue lorsque le personnel inscrit des étudiants à des cours ou cohortes."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Rappels et mises à jour des sessions en direct",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "Envoyer un e-mail aux étudiants avant les sessions en direct et lors des changements de détails."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Rappels d'objectifs de cohorte",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "Envoyer un e-mail aux étudiants concernant les objectifs d'apprentissage à venir ou en retard."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Exercice assigné",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "Envoyer un e-mail aux étudiants lorsqu'un exercice est assigné ou rappelé."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Annonces et commentaires du cours",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "Envoyer un e-mail aux membres du cours concernant les nouvelles publications et commentaires."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Résultats de notation",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "Envoyer un e-mail aux étudiants lorsque leur soumission est notée ou mise à jour."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Nouvelles soumissions à noter",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "Envoyer un e-mail aux tuteurs lorsqu'un étudiant soumet un travail à noter."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Nouvel étudiant s'inscrit",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "Envoyer un e-mail aux tuteurs lorsqu'un étudiant s'inscrit à un cours."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Paramètres par défaut de l'organisation",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Lorsqu'ils sont désactivés ici, personne dans votre organisation ne reçoit cet e-mail. Les membres peuvent toujours se désabonner des e-mails que vous laissez activés.",
|
||||||
"save": "Save organization defaults",
|
"save": "Enregistrer les paramètres par défaut de l'organisation",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Paramètres de notification de l'organisation enregistrés"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Vos préférences e-mail",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Enregistrer les préférences personnelles",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Préférences de notification personnelles enregistrées",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Ces préférences s'appliquent aux e-mails qui vous sont envoyés dans cette organisation. Votre organisation peut toujours désactiver certains e-mails pour tout le monde.",
|
||||||
|
"description_with_org": "Ces préférences s'appliquent aux e-mails qui vous sont envoyés dans {orgName}. Votre organisation peut toujours désactiver certains e-mails pour tout le monde."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Inscription",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "E-mails de bienvenue envoyés lorsque le personnel ajoute des membres à des cours ou cohortes."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Cohorte",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Rappels concernant les objectifs d'apprentissage et les échéances de la cohorte."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Cours",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Inscriptions, soumissions, leçons, annonces et complétions dans vos cours."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Choisissez les notifications par e-mail que vous recevez de ClassroomIO."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "अनुपालन",
|
"title": "अनुपालन",
|
||||||
"due_date": "देय",
|
"due_date": "देय",
|
||||||
"valid_until": "तक वैध है"
|
"valid_until": "तक वैध है"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "आपकी प्रगति",
|
||||||
|
"completed": "{total} में से {completed} पूर्ण",
|
||||||
|
"lessons": "{total} में से {completed} पाठ",
|
||||||
|
"exercises": "{total} में से {completed} अभ्यास"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "टीमें",
|
"teams_tab": "टीमें",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "एआई ट्यूटर",
|
"ai_tutor_tab": "एआई ट्यूटर",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "सूचनाएँ"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "अकादमी खोलें"
|
"view_site": "अकादमी खोलें"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "सूचनाएँ",
|
||||||
"page_title": "Notifications",
|
"page_title": "सूचनाएँ",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "चुनें कि आप कौन सी ईमेल सूचनाएँ प्राप्त करते हैं और व्यवस्थापक के रूप में आपका संगठन कौन से ईमेल भेजता है।",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "पाठ्यक्रम पूर्णता और प्रमाणपत्र",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "जब छात्र पाठ्यक्रम पूरा करें और प्रमाणपत्र प्राप्त करें तो ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "नामांकन स्वागत ईमेल",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "जब स्टाफ छात्रों को पाठ्यक्रमों या समूहों में नामांकित करे तो स्वागत ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "लाइव सत्र अनुस्मारक और अपडेट",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "लाइव सत्रों से पहले और सत्र विवरण बदलने पर छात्रों को ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "समूह लक्ष्य अनुस्मारक",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "आगामी या अतिदेय समूह सीखने के लक्ष्यों के बारे में छात्रों को ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "अभ्यास असाइन किया गया",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "जब अभ्यास असाइन या याद दिलाया जाए तो छात्रों को ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "पाठ्यक्रम घोषणाएँ और टिप्पणियाँ",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "न्यूज़फ़ीड पर नई पोस्ट और टिप्पणियों के बारे में पाठ्यक्रम सदस्यों को ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "ग्रेडिंग परिणाम",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "जब उनका सबमिशन ग्रेड या अपडेट हो तो छात्रों को ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "ग्रेडिंग के लिए नए सबमिशन",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "जब छात्र ग्रेडिंग की आवश्यकता वाला कार्य जमा करे तो ट्यूटर्स को ईमेल भेजें।"
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "नया छात्र शामिल होता है",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "जब कोई छात्र पाठ्यक्रम में नामांकित हो तो ट्यूटर्स को ईमेल भेजें।"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "संगठन डिफ़ॉल्ट",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "यहाँ अक्षम करने पर, आपके संगठन में कोई भी वह ईमेल नहीं प्राप्त करेगा। सदस्य अभी भी उन ईमेल से ऑप्ट आउट कर सकते हैं जिन्हें आप सक्षम रखते हैं।",
|
||||||
"save": "Save organization defaults",
|
"save": "संगठन डिफ़ॉल्ट सहेजें",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "संगठन सूचना डिफ़ॉल्ट सहेजे गए"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "आपकी ईमेल प्राथमिकताएँ",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "व्यक्तिगत प्राथमिकताएँ सहेजें",
|
||||||
"save": "Save personal preferences",
|
"save_success": "व्यक्तिगत सूचना प्राथमिकताएँ सहेजी गईं",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "ये उन ईमेल पर लागू होते हैं जो इस संगठन में आपको भेजे जाते हैं। आपका संगठन फिर भी कुछ ईमेल सभी के लिए बंद कर सकता है।",
|
||||||
|
"description_with_org": "ये उन ईमेल पर लागू होते हैं जो {orgName} में आपको भेजे जाते हैं। आपका संगठन फिर भी कुछ ईमेल सभी के लिए बंद कर सकता है।"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "नामांकन",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "स्वागत ईमेल जब स्टाफ सदस्यों को पाठ्यक्रमों या समूहों में जोड़ता है।"
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "समूह",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "समूह सीखने के लक्ष्यों और समय सीमा के बारे में अनुस्मारक।"
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "पाठ्यक्रम",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "आपके पाठ्यक्रमों में नामांकन, सबमिशन, पाठ, घोषणाएँ और पूर्णता।"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "चुनें कि आप ClassroomIO से कौन सी ईमेल सूचनाएँ प्राप्त करते हैं।"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Zgodność",
|
"title": "Zgodność",
|
||||||
"due_date": "Termin",
|
"due_date": "Termin",
|
||||||
"valid_until": "Ważne do"
|
"valid_until": "Ważne do"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Twój postęp",
|
||||||
|
"completed": "Ukończono {completed} z {total}",
|
||||||
|
"lessons": "{completed} z {total} lekcji",
|
||||||
|
"exercises": "{completed} z {total} ćwiczeń"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "Zespoły",
|
"teams_tab": "Zespoły",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "Nauczyciel AI",
|
"ai_tutor_tab": "Nauczyciel AI",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "Powiadomienia"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "Otwarta Akademia"
|
"view_site": "Otwarta Akademia"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Powiadomienia",
|
||||||
"page_title": "Notifications",
|
"page_title": "Powiadomienia",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Wybierz, które powiadomienia e-mail otrzymujesz, a jako administrator, które e-maile wysyła Twoja organizacja.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Ukończenie kursu i certyfikaty",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "Wyślij e-mail do studentów, gdy ukończą kurs i otrzymają certyfikat."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "Powitalne e-maile zapisów",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Wysyłaj e-maile powitalne, gdy personel zapisuje studentów na kursy lub kohorty."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Przypomnienia i aktualizacje sesji na żywo",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "Wyślij e-mail do studentów przed sesjami na żywo i gdy zmienią się szczegóły sesji."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Przypomnienia o celach kohorty",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "Wyślij e-mail do studentów o nadchodzących lub zaległych celach nauki kohorty."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Przypisane ćwiczenie",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "Wyślij e-mail do studentów, gdy ćwiczenie zostanie przypisane lub przypomniane."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Ogłoszenia kursu i komentarze",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "Wyślij e-mail do członków kursu o nowych postach i komentarzach w aktualnościach."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Wyniki oceniania",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "Wyślij e-mail do studentów, gdy ich zgłoszenie zostanie ocenione lub zaktualizowane."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Nowe zgłoszenia do oceny",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "Wyślij e-mail do tutorów, gdy student prześle pracę wymagającą oceny."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Nowy student dołącza",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "Wyślij e-mail do tutorów, gdy student zapisze się na kurs."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Domyślne ustawienia organizacji",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Po wyłączeniu tutaj nikt w Twojej organizacji nie otrzyma tego e-maila. Członkowie nadal mogą zrezygnować z e-maili, które pozostawisz włączone.",
|
||||||
"save": "Save organization defaults",
|
"save": "Zapisz domyślne ustawienia organizacji",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Domyślne ustawienia powiadomień organizacji zapisane"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Twoje preferencje e-mail",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Zapisz preferencje osobiste",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Osobiste preferencje powiadomień zapisane",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Dotyczą wiadomości e-mail wysyłanych do Ciebie w tej organizacji. Twoja organizacja nadal może wyłączyć niektóre wiadomości dla wszystkich.",
|
||||||
|
"description_with_org": "Dotyczą wiadomości e-mail wysyłanych do Ciebie w {orgName}. Twoja organizacja nadal może wyłączyć niektóre wiadomości dla wszystkich."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Zapisy",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "E-maile powitalne wysyłane, gdy personel dodaje członków do kursów lub kohort."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Kohorta",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Przypomnienia o celach nauki kohorty i terminach."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Kurs",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Zapisy, zgłoszenia, lekcje, ogłoszenia i ukończenia w Twoich kursach."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Wybierz, które powiadomienia e-mail otrzymujesz od ClassroomIO."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Conformidade",
|
"title": "Conformidade",
|
||||||
"due_date": "Devido",
|
"due_date": "Devido",
|
||||||
"valid_until": "Válido até"
|
"valid_until": "Válido até"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Seu progresso",
|
||||||
|
"completed": "{completed} de {total} concluídos",
|
||||||
|
"lessons": "{completed} de {total} aulas",
|
||||||
|
"exercises": "{completed} de {total} exercícios"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "Equipes",
|
"teams_tab": "Equipes",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "Tutor de IA",
|
"ai_tutor_tab": "Tutor de IA",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "Notificações"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "Academia Aberta"
|
"view_site": "Academia Aberta"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Notificações",
|
||||||
"page_title": "Notifications",
|
"page_title": "Notificações",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Escolha quais notificações por e-mail você recebe e, como administrador, quais e-mails sua organização envia.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Conclusão do curso e certificados",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "Enviar e-mail aos alunos quando concluírem um curso e receberem um certificado."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "E-mails de boas-vindas de matrícula",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Enviar e-mails de boas-vindas quando a equipe matricular alunos em cursos ou coortes."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Lembretes e atualizações de sessões ao vivo",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "Enviar e-mail aos alunos antes de sessões ao vivo e quando os detalhes mudarem."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Lembretes de metas da coorte",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "Enviar e-mail aos alunos sobre metas de aprendizagem próximas ou atrasadas."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Exercício atribuído",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "Enviar e-mail aos alunos quando um exercício for atribuído ou lembrado."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Anúncios e comentários do curso",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "Enviar e-mail aos membros do curso sobre novas publicações e comentários no feed."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Resultados de avaliação",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "Enviar e-mail aos alunos quando o envio for avaliado ou atualizado."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Novos envios para avaliar",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "Enviar e-mail aos tutores quando um aluno envia trabalho que precisa de avaliação."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Novo aluno se inscreve",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "Enviar e-mail aos tutores quando um aluno se matricula em um curso."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Padrões da organização",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Quando desativado aqui, ninguém na sua organização recebe esse e-mail. Os membros ainda podem optar por não receber e-mails que você mantiver ativados.",
|
||||||
"save": "Save organization defaults",
|
"save": "Salvar padrões da organização",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Padrões de notificação da organização salvos"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Suas preferências de e-mail",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Salvar preferências pessoais",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Preferências de notificação pessoais salvas",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Aplicam-se aos e-mails enviados para você nesta organização. Sua organização ainda pode desativar certos e-mails para todos.",
|
||||||
|
"description_with_org": "Aplicam-se aos e-mails enviados para você em {orgName}. Sua organização ainda pode desativar certos e-mails para todos."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Matrícula",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "E-mails de boas-vindas enviados quando a equipe adiciona membros a cursos ou coortes."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Coorte",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Lembretes sobre metas de aprendizagem e prazos da coorte."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Curso",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Matrículas, envios, aulas, anúncios e conclusões nos seus cursos."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Escolha quais notificações por e-mail você recebe do ClassroomIO."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Соответствие",
|
"title": "Соответствие",
|
||||||
"due_date": "Срок погашения",
|
"due_date": "Срок погашения",
|
||||||
"valid_until": "Действительно до"
|
"valid_until": "Действительно до"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Ваш прогресс",
|
||||||
|
"completed": "Завершено {completed} из {total}",
|
||||||
|
"lessons": "{completed} из {total} уроков",
|
||||||
|
"exercises": "{completed} из {total} упражнений"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "Команды",
|
"teams_tab": "Команды",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "Репетитор по искусственному интеллекту",
|
"ai_tutor_tab": "Репетитор по искусственному интеллекту",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "Уведомления"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "Открытая Академия"
|
"view_site": "Открытая Академия"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Уведомления",
|
||||||
"page_title": "Notifications",
|
"page_title": "Уведомления",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Выберите, какие уведомления по электронной почте вы получаете, а как администратор — какие письма отправляет ваша организация.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Завершение курса и сертификаты",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "Отправлять письмо студентам, когда они завершают курс и получают сертификат."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "Приветственные письма при записи",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Отправлять приветственные письма, когда сотрудники записывают студентов на курсы или когорты."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Напоминания и обновления живых сессий",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "Отправлять письмо студентам перед живыми сессиями и при изменении деталей сессии."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Напоминания о целях когорты",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "Отправлять письмо студентам о предстоящих или просроченных целях обучения когорты."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Назначено упражнение",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "Отправлять письмо студентам, когда упражнение назначено или напоминается."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Объявления курса и комментарии",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "Отправлять письмо участникам курса о новых публикациях и комментариях в ленте."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Результаты оценки",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "Отправлять письмо студентам, когда их работа оценена или обновлена."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Новые работы для оценки",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "Отправлять письмо наставникам, когда студент сдаёт работу, требующую оценки."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Новый студент записывается",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "Отправлять письмо наставникам, когда студент записывается на курс."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Настройки организации по умолчанию",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Если отключено здесь, никто в вашей организации не получит это письмо. Участники по-прежнему могут отказаться от писем, которые вы оставили включёнными.",
|
||||||
"save": "Save organization defaults",
|
"save": "Сохранить настройки организации по умолчанию",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Настройки уведомлений организации сохранены"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Ваши настройки электронной почты",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Сохранить личные настройки",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Личные настройки уведомлений сохранены",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Они применяются к письмам, которые вам отправляют в этой организации. Организация по-прежнему может отключить некоторые письма для всех.",
|
||||||
|
"description_with_org": "Они применяются к письмам, которые вам отправляют в {orgName}. Организация по-прежнему может отключить некоторые письма для всех."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Запись",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "Приветственные письма, отправляемые, когда сотрудники добавляют участников в курсы или когорты."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Когорта",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Напоминания о целях обучения когорты и сроках."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Курс",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Записи, сдачи, уроки, объявления и завершения в ваших курсах."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Выберите, какие уведомления по электронной почте вы получаете от ClassroomIO."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -2370,6 +2370,12 @@
|
|||||||
"title": "Tuân thủ",
|
"title": "Tuân thủ",
|
||||||
"due_date": "Đến hạn",
|
"due_date": "Đến hạn",
|
||||||
"valid_until": "Có hiệu lực cho đến khi"
|
"valid_until": "Có hiệu lực cho đến khi"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Tiến độ của bạn",
|
||||||
|
"completed": "Đã hoàn thành {completed} trong số {total}",
|
||||||
|
"lessons": "{completed} trong số {total} bài học",
|
||||||
|
"exercises": "{completed} trong số {total} bài tập"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navItems": {
|
"navItems": {
|
||||||
@@ -3463,7 +3469,7 @@
|
|||||||
"teams_tab": "Đội",
|
"teams_tab": "Đội",
|
||||||
"ai_credits_tab": "AI Credits",
|
"ai_credits_tab": "AI Credits",
|
||||||
"ai_tutor_tab": "Gia sư AI",
|
"ai_tutor_tab": "Gia sư AI",
|
||||||
"notifications_tab": "Notifications"
|
"notifications_tab": "Thông báo"
|
||||||
},
|
},
|
||||||
"subheadings": {
|
"subheadings": {
|
||||||
"view_site": "Học viện mở"
|
"view_site": "Học viện mở"
|
||||||
@@ -3639,73 +3645,75 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications",
|
"heading": "Thông báo",
|
||||||
"page_title": "Notifications",
|
"page_title": "Thông báo",
|
||||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
"page_subtitle": "Chọn thông báo email bạn nhận và, với tư cách quản trị viên, email mà tổ chức của bạn gửi.",
|
||||||
"toggles": {
|
"toggles": {
|
||||||
"courseCompletion": {
|
"courseCompletion": {
|
||||||
"label": "Course completion and certificates",
|
"label": "Hoàn thành khóa học và chứng chỉ",
|
||||||
"description": "Email students when they complete a course and earn a certificate."
|
"description": "Gửi email cho học viên khi họ hoàn thành khóa học và nhận chứng chỉ."
|
||||||
},
|
},
|
||||||
"enrollmentWelcome": {
|
"enrollmentWelcome": {
|
||||||
"label": "Enrollment welcome emails",
|
"label": "Email chào mừng ghi danh",
|
||||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
"description": "Gửi email chào mừng khi nhân viên ghi danh học viên vào khóa học hoặc nhóm học."
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"label": "Live session reminders and updates",
|
"label": "Nhắc nhở và cập nhật buổi học trực tiếp",
|
||||||
"description": "Email students before live sessions and when session details change."
|
"description": "Gửi email cho học viên trước buổi học trực tiếp và khi chi tiết buổi học thay đổi."
|
||||||
},
|
},
|
||||||
"cohortReminder": {
|
"cohortReminder": {
|
||||||
"label": "Cohort goal reminders",
|
"label": "Nhắc nhở mục tiêu nhóm học",
|
||||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
"description": "Gửi email cho học viên về mục tiêu học tập sắp tới hoặc quá hạn."
|
||||||
},
|
},
|
||||||
"quizAssigned": {
|
"quizAssigned": {
|
||||||
"label": "Exercise assigned",
|
"label": "Bài tập được giao",
|
||||||
"description": "Email students when an exercise is assigned or nudged."
|
"description": "Gửi email cho học viên khi bài tập được giao hoặc nhắc nhở."
|
||||||
},
|
},
|
||||||
"newsfeed": {
|
"newsfeed": {
|
||||||
"label": "Course announcements and comments",
|
"label": "Thông báo và bình luận khóa học",
|
||||||
"description": "Email course members about new posts and comments on the newsfeed."
|
"description": "Gửi email cho thành viên khóa học về bài đăng và bình luận mới trên bảng tin."
|
||||||
},
|
},
|
||||||
"gradingResult": {
|
"gradingResult": {
|
||||||
"label": "Grading results",
|
"label": "Kết quả chấm điểm",
|
||||||
"description": "Email students when their submission is graded or updated."
|
"description": "Gửi email cho học viên khi bài nộp được chấm hoặc cập nhật."
|
||||||
},
|
},
|
||||||
"newSubmission": {
|
"newSubmission": {
|
||||||
"label": "New submissions to grade",
|
"label": "Bài nộp mới cần chấm",
|
||||||
"description": "Email tutors when a student submits work that needs grading."
|
"description": "Gửi email cho gia sư khi học viên nộp bài cần chấm điểm."
|
||||||
},
|
},
|
||||||
"newStudent": {
|
"newStudent": {
|
||||||
"label": "New student joins",
|
"label": "Học viên mới tham gia",
|
||||||
"description": "Email tutors when a student enrolls in a course."
|
"description": "Gửi email cho gia sư khi học viên ghi danh vào khóa học."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"heading": "Organization defaults",
|
"heading": "Mặc định của tổ chức",
|
||||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
"description": "Khi tắt ở đây, không ai trong tổ chức của bạn nhận email đó. Thành viên vẫn có thể từ chối các email bạn để bật.",
|
||||||
"save": "Save organization defaults",
|
"save": "Lưu mặc định của tổ chức",
|
||||||
"save_success": "Organization notification defaults saved"
|
"save_success": "Đã lưu mặc định thông báo của tổ chức"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"heading": "Your email preferences",
|
"heading": "Tùy chọn email của bạn",
|
||||||
"description": "These apply to emails sent to you across every organization you belong to. Your organization may still disable certain emails for everyone.",
|
"save": "Lưu tùy chọn cá nhân",
|
||||||
"save": "Save personal preferences",
|
"save_success": "Đã lưu tùy chọn thông báo cá nhân",
|
||||||
"save_success": "Personal notification preferences saved"
|
"description": "Áp dụng cho email gửi đến bạn trong tổ chức này. Tổ chức của bạn vẫn có thể tắt một số email cho mọi người.",
|
||||||
|
"description_with_org": "Áp dụng cho email gửi đến bạn trong {orgName}. Tổ chức của bạn vẫn có thể tắt một số email cho mọi người."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"heading": "Enrollment",
|
"heading": "Ghi danh",
|
||||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
"description": "Email chào mừng được gửi khi nhân viên thêm thành viên vào khóa học hoặc nhóm học."
|
||||||
},
|
},
|
||||||
"cohort": {
|
"cohort": {
|
||||||
"heading": "Cohort",
|
"heading": "Nhóm học",
|
||||||
"description": "Reminders about cohort learning goals and deadlines."
|
"description": "Nhắc nhở về mục tiêu học tập và hạn chót của nhóm học."
|
||||||
},
|
},
|
||||||
"course": {
|
"course": {
|
||||||
"heading": "Course",
|
"heading": "Khóa học",
|
||||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
"description": "Ghi danh, bài nộp, bài học, thông báo và hoàn thành trong các khóa học của bạn."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"personal_page_subtitle": "Chọn thông báo email bạn nhận từ ClassroomIO."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profileMenu": {
|
"profileMenu": {
|
||||||
|
|||||||
@@ -16,7 +16,16 @@
|
|||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!data.lesson) return;
|
if (!data.lessonId) return;
|
||||||
|
|
||||||
|
if (!data.lesson) {
|
||||||
|
if (lessonApi.lesson?.id === data.lessonId) {
|
||||||
|
lessonApi.lesson = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lesson = data.lesson;
|
const lesson = data.lesson;
|
||||||
if (lessonApi.lesson?.id === lesson.id) return;
|
if (lessonApi.lesson?.id === lesson.id) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NotificationsPage } from '$features/settings/pages';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t.get('settings.notifications.page_title')} - ClassroomIO</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<Page.Header>
|
||||||
|
<Page.HeaderContent>
|
||||||
|
<Page.Title>{$t('settings.notifications.heading')}</Page.Title>
|
||||||
|
<Page.Subtitle>{$t('settings.notifications.personal_page_subtitle')}</Page.Subtitle>
|
||||||
|
</Page.HeaderContent>
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Body>
|
||||||
|
{#snippet child()}
|
||||||
|
<NotificationsPage />
|
||||||
|
{/snippet}
|
||||||
|
</Page.Body>
|
||||||
@@ -16,7 +16,10 @@
|
|||||||
"community",
|
"community",
|
||||||
"organization",
|
"organization",
|
||||||
"student-dashboard",
|
"student-dashboard",
|
||||||
|
|
||||||
|
"---Enterprise---",
|
||||||
"enterprise-sso",
|
"enterprise-sso",
|
||||||
|
"token-auth",
|
||||||
|
|
||||||
"---Get started---",
|
"---Get started---",
|
||||||
"create-first-course",
|
"create-first-course",
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
title: Token Auth
|
||||||
|
description: Sign users in to ClassroomIO from your own app with a short-lived JWT.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
|
|
||||||
|
# Token Auth
|
||||||
|
|
||||||
|
Token Auth lets an external app sign a user in to ClassroomIO without asking for a ClassroomIO password. Your backend signs a short-lived JWT with your organization's Token Auth secret, then redirects the user's browser to ClassroomIO's token exchange URL.
|
||||||
|
|
||||||
|
Use Token Auth when you already authenticate users in another product and want a trusted handoff into your ClassroomIO academy.
|
||||||
|
|
||||||
|
<Callout type="info" title="Enterprise Feature">
|
||||||
|
Token Auth requires an **Enterprise** plan.
|
||||||
|
|
||||||
|
**Cloud:** [Contact us](mailto:[email protected]) to upgrade.
|
||||||
|
|
||||||
|
**Self-hosted:** Requires an Enterprise license key. Set `LICENSE_KEY` in the API service environment.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. An organization admin generates a Token Auth signing secret in ClassroomIO.
|
||||||
|
2. Your backend authenticates the user in your own app.
|
||||||
|
3. Your backend signs a short-lived `HS256` JWT with the ClassroomIO signing secret.
|
||||||
|
4. Your app redirects the browser to `/api/auth/token-exchange?token=...&redirect=...`.
|
||||||
|
5. ClassroomIO verifies the token, finds or creates the user, adds them to the organization, creates a session, and redirects them to the requested path.
|
||||||
|
|
||||||
|
## Set Up Token Auth
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step>
|
||||||
|
Sign in to ClassroomIO as an organization admin.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
Open **Settings** → **Authentication** → **Token Auth**.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
Click **Generate secret** and copy the secret immediately. The full secret is shown only once.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
Store the secret in your backend secret manager or environment variables.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
Enable Token Auth after your integration is ready.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Token Exchange URL
|
||||||
|
|
||||||
|
Cloud:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://api.classroomio.com/api/auth/token-exchange
|
||||||
|
```
|
||||||
|
|
||||||
|
Self-hosted:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{PUBLIC_SERVER_URL}/api/auth/token-exchange
|
||||||
|
```
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Parameter | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `token` | Yes | The signed JWT. |
|
||||||
|
| `redirect` | No | A ClassroomIO path to open after login. Must start with `/`. Invalid external URLs are ignored and users are sent to `/`. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://api.classroomio.com/api/auth/token-exchange?token=eyJ...&redirect=/courses/my-course
|
||||||
|
```
|
||||||
|
|
||||||
|
## JWT Requirements
|
||||||
|
|
||||||
|
Token Auth accepts `HS256` JWTs signed with your organization's Token Auth secret.
|
||||||
|
|
||||||
|
Required claims:
|
||||||
|
|
||||||
|
| Claim | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sub` | string | Stable user identifier from your system. |
|
||||||
|
| `email` | string | User email address. ClassroomIO lowercases it before lookup. |
|
||||||
|
| `iat` | number | Issued-at timestamp. |
|
||||||
|
| `exp` | number | Expiration timestamp. |
|
||||||
|
|
||||||
|
Optional claims:
|
||||||
|
|
||||||
|
| Claim | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | Display name. If omitted, ClassroomIO uses the email username. |
|
||||||
|
| `avatar` | string | Public image URL for the user's avatar. |
|
||||||
|
|
||||||
|
Tokens must be fresh. ClassroomIO rejects tokens older than 5 minutes, so use a short expiration such as 2 minutes.
|
||||||
|
|
||||||
|
## Node Example
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
|
||||||
|
const tokenAuthSecret = process.env.CLASSROOMIO_TOKEN_AUTH_SECRET;
|
||||||
|
const classroomioAuthUrl = 'https://api.classroomio.com/api/auth/token-exchange';
|
||||||
|
|
||||||
|
export async function buildClassroomioLoginUrl(user) {
|
||||||
|
const secret = new TextEncoder().encode(tokenAuthSecret);
|
||||||
|
|
||||||
|
const token = await new SignJWT({
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
avatar: user.avatarUrl
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('2m')
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
|
const url = new URL(classroomioAuthUrl);
|
||||||
|
url.searchParams.set('token', token);
|
||||||
|
url.searchParams.set('redirect', '/');
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirect the browser to the returned URL after the user clicks "Open ClassroomIO" or any equivalent action in your app.
|
||||||
|
|
||||||
|
## User Provisioning
|
||||||
|
|
||||||
|
When the token is valid:
|
||||||
|
|
||||||
|
- If a ClassroomIO user already exists with the email address, that user is signed in.
|
||||||
|
- If no user exists, ClassroomIO creates one with a random password.
|
||||||
|
- ClassroomIO adds the user to the organization tied to the signing secret.
|
||||||
|
- If the email has a pending invite or pre-created audience row, ClassroomIO links that record first. Otherwise, the user joins with the organization's default role.
|
||||||
|
- If `avatar` is present, ClassroomIO updates the user's profile avatar.
|
||||||
|
|
||||||
|
## Rotating the Secret
|
||||||
|
|
||||||
|
Rotate the secret from **Settings** → **Authentication** → **Token Auth** when a secret may have been exposed or as part of regular credential rotation.
|
||||||
|
|
||||||
|
After rotation, update your backend to sign new tokens with the new secret. Old tokens signed with the previous secret stop working.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No active token auth configured"
|
||||||
|
|
||||||
|
Token Auth is not enabled for the organization. Generate a secret and enable Token Auth in the dashboard.
|
||||||
|
|
||||||
|
### "Invalid token"
|
||||||
|
|
||||||
|
Check that:
|
||||||
|
|
||||||
|
- The token uses `HS256`.
|
||||||
|
- The token is signed with the current Token Auth secret.
|
||||||
|
- The token has not expired.
|
||||||
|
- The token was issued less than 5 minutes ago.
|
||||||
|
|
||||||
|
### "Invalid token payload"
|
||||||
|
|
||||||
|
Verify that the JWT includes `sub`, `email`, `iat`, and `exp`, and that `email` is a valid email address.
|
||||||
|
|
||||||
|
### Redirect sends users to `/`
|
||||||
|
|
||||||
|
The `redirect` value must be a relative path that starts with `/`. External URLs are ignored for safety.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Sign tokens only on your backend. Never expose the Token Auth secret in browser code.
|
||||||
|
- Keep token lifetimes short. Two minutes is usually enough for a browser redirect.
|
||||||
|
- Rotate the secret immediately if it is logged, leaked, or committed.
|
||||||
|
- Use HTTPS in production.
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
||||||
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
|
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
|
||||||
|
"db:backfill-member-email-notifications": "tsx src/scripts/backfill-member-email-notifications-from-profile.ts",
|
||||||
"db:update-user-email": "tsx src/scripts/update-user-email.ts",
|
"db:update-user-email": "tsx src/scripts/update-user-email.ts",
|
||||||
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
|
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
|
||||||
"db:downgrade-expired-subscriptions": "tsx src/scripts/downgrade-expired-subscriptions.ts",
|
"db:downgrade-expired-subscriptions": "tsx src/scripts/downgrade-expired-subscriptions.ts",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "organizationmember_email_notifications" (
|
||||||
|
"organization_member_id" bigint PRIMARY KEY NOT NULL,
|
||||||
|
"new_student" boolean DEFAULT true NOT NULL,
|
||||||
|
"new_submission" boolean DEFAULT true NOT NULL,
|
||||||
|
"grading_result" boolean DEFAULT true NOT NULL,
|
||||||
|
"newsfeed" boolean DEFAULT true NOT NULL,
|
||||||
|
"quiz_assigned" boolean DEFAULT true NOT NULL,
|
||||||
|
"cohort_reminder" boolean DEFAULT true NOT NULL,
|
||||||
|
"session" boolean DEFAULT true NOT NULL,
|
||||||
|
"course_completion" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "organizationmember_email_notifications" ADD CONSTRAINT "organizationmember_email_notifications_organization_member_id_fkey" FOREIGN KEY ("organization_member_id") REFERENCES "public"."organizationmember"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1783909129074,
|
"when": 1783909129074,
|
||||||
"tag": "0003_profile_settings",
|
"tag": "0003_profile_settings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783938329367,
|
||||||
|
"tag": "0004_organizationmember_email_notifications",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { db } from '@db/drizzle';
|
||||||
|
import * as schema from '@db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { EmailNotificationSettings } from '@cio/utils/notifications';
|
||||||
|
|
||||||
|
export async function getOrganizationEmailNotificationSettings(
|
||||||
|
orgId: string
|
||||||
|
): Promise<EmailNotificationSettings | undefined> {
|
||||||
|
try {
|
||||||
|
const [organization] = await db
|
||||||
|
.select({ settings: schema.organization.settings })
|
||||||
|
.from(schema.organization)
|
||||||
|
.where(eq(schema.organization.id, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return organization?.settings?.emailNotifications;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getOrganizationEmailNotificationSettings error:', error);
|
||||||
|
throw new Error('Failed to get organization email notification settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,55 +4,11 @@ import {
|
|||||||
type PersonalEmailNotificationSettings
|
type PersonalEmailNotificationSettings
|
||||||
} from '@cio/utils/notifications';
|
} from '@cio/utils/notifications';
|
||||||
|
|
||||||
import { getProfileByEmail, getProfileById } from '../auth/profile';
|
import { getOrganizationEmailNotificationSettings } from './email-preferences-org';
|
||||||
import { db } from '@db/drizzle';
|
import { getPersonalEmailNotificationSettingsForOrg } from './organizationmember-email-notifications';
|
||||||
import * as schema from '@db/schema';
|
import { getProfileByEmail } from '../auth/profile';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import type { EmailId } from '@cio/email';
|
import type { EmailId } from '@cio/email';
|
||||||
|
|
||||||
export async function getOrganizationEmailNotificationSettings(
|
|
||||||
orgId: string
|
|
||||||
): Promise<EmailNotificationSettings | undefined> {
|
|
||||||
try {
|
|
||||||
const [organization] = await db
|
|
||||||
.select({ settings: schema.organization.settings })
|
|
||||||
.from(schema.organization)
|
|
||||||
.where(eq(schema.organization.id, orgId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return organization?.settings?.emailNotifications;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('getOrganizationEmailNotificationSettings error:', error);
|
|
||||||
throw new Error('Failed to get organization email notification settings');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProfileEmailNotificationSettingsById(
|
|
||||||
profileId: string
|
|
||||||
): Promise<PersonalEmailNotificationSettings | undefined> {
|
|
||||||
try {
|
|
||||||
const profile = await getProfileById(profileId);
|
|
||||||
|
|
||||||
return profile?.settings?.emailNotifications;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('getProfileEmailNotificationSettingsById error:', error);
|
|
||||||
throw new Error('Failed to get profile email notification settings');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProfileEmailNotificationSettingsByEmail(
|
|
||||||
email: string
|
|
||||||
): Promise<PersonalEmailNotificationSettings | undefined> {
|
|
||||||
try {
|
|
||||||
const profile = await getProfileByEmail(email);
|
|
||||||
|
|
||||||
return profile?.settings?.emailNotifications;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('getProfileEmailNotificationSettingsByEmail error:', error);
|
|
||||||
throw new Error('Failed to get profile email notification settings');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function shouldSendEmail(input: {
|
export async function shouldSendEmail(input: {
|
||||||
emailId: EmailId;
|
emailId: EmailId;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
@@ -67,7 +23,7 @@ export async function shouldSendEmail(input: {
|
|||||||
*/
|
*/
|
||||||
export class EmailPreferenceLookupCache {
|
export class EmailPreferenceLookupCache {
|
||||||
private orgSettingsById = new Map<string, EmailNotificationSettings | undefined>();
|
private orgSettingsById = new Map<string, EmailNotificationSettings | undefined>();
|
||||||
private profileSettingsById = new Map<string, PersonalEmailNotificationSettings | undefined>();
|
private personalSettingsByOrgMember = new Map<string, PersonalEmailNotificationSettings | undefined>();
|
||||||
private profileIdByEmail = new Map<string, string | undefined>();
|
private profileIdByEmail = new Map<string, string | undefined>();
|
||||||
|
|
||||||
async shouldSend(input: {
|
async shouldSend(input: {
|
||||||
@@ -84,7 +40,8 @@ export class EmailPreferenceLookupCache {
|
|||||||
profileId = await this.getProfileIdByEmail(input.recipientEmail);
|
profileId = await this.getProfileIdByEmail(input.recipientEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
const personalSettings = profileId ? await this.getProfileSettings(profileId) : undefined;
|
const personalSettings =
|
||||||
|
profileId && input.organizationId ? await this.getPersonalSettings(profileId, input.organizationId) : undefined;
|
||||||
|
|
||||||
return resolveEmailDelivery(input.emailId, orgSettings, personalSettings);
|
return resolveEmailDelivery(input.emailId, orgSettings, personalSettings);
|
||||||
}
|
}
|
||||||
@@ -107,12 +64,17 @@ export class EmailPreferenceLookupCache {
|
|||||||
return this.profileIdByEmail.get(email);
|
return this.profileIdByEmail.get(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getProfileSettings(profileId: string): Promise<PersonalEmailNotificationSettings | undefined> {
|
private async getPersonalSettings(
|
||||||
if (!this.profileSettingsById.has(profileId)) {
|
profileId: string,
|
||||||
const settings = await getProfileEmailNotificationSettingsById(profileId);
|
organizationId: string
|
||||||
this.profileSettingsById.set(profileId, settings);
|
): Promise<PersonalEmailNotificationSettings | undefined> {
|
||||||
|
const cacheKey = `${profileId}:${organizationId}`;
|
||||||
|
|
||||||
|
if (!this.personalSettingsByOrgMember.has(cacheKey)) {
|
||||||
|
const settings = await getPersonalEmailNotificationSettingsForOrg(profileId, organizationId);
|
||||||
|
this.personalSettingsByOrgMember.set(cacheKey, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.profileSettingsById.get(profileId);
|
return this.personalSettingsByOrgMember.get(cacheKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export * from './email-preferences';
|
export * from './email-preferences';
|
||||||
|
export * from './email-preferences-org';
|
||||||
|
export * from './organizationmember-email-notifications';
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_MEMBER_EMAIL_NOTIFICATION_ROW,
|
||||||
|
memberEmailNotificationRowToApiResponse,
|
||||||
|
memberEmailNotificationRowToPersonalSettings,
|
||||||
|
personalSettingsToMemberEmailNotificationRow,
|
||||||
|
type MemberEmailNotificationRow
|
||||||
|
} from '@cio/utils/notifications';
|
||||||
|
import type { PersonalEmailNotificationSettings } from '@cio/utils/notifications';
|
||||||
|
import type { TMemberEmailNotificationPreferencesUpdate } from '@cio/utils/validation/notifications';
|
||||||
|
|
||||||
|
import { getOrganizationMemberIdByOrgAndProfile } from '../organization/organization';
|
||||||
|
import { db } from '@db/drizzle';
|
||||||
|
import * as schema from '@db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
function rowFromRecord(
|
||||||
|
record: typeof schema.organizationmemberEmailNotifications.$inferSelect | undefined
|
||||||
|
): MemberEmailNotificationRow | null {
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newStudent: record.newStudent,
|
||||||
|
newSubmission: record.newSubmission,
|
||||||
|
gradingResult: record.gradingResult,
|
||||||
|
newsfeed: record.newsfeed,
|
||||||
|
quizAssigned: record.quizAssigned,
|
||||||
|
cohortReminder: record.cohortReminder,
|
||||||
|
session: record.session,
|
||||||
|
courseCompletion: record.courseCompletion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationMemberEmailNotifications(
|
||||||
|
profileId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<MemberEmailNotificationRow | null> {
|
||||||
|
try {
|
||||||
|
const organizationMemberId = await getOrganizationMemberIdByOrgAndProfile(organizationId, profileId);
|
||||||
|
|
||||||
|
if (!organizationMemberId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.organizationmemberEmailNotifications)
|
||||||
|
.where(eq(schema.organizationmemberEmailNotifications.organizationMemberId, organizationMemberId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return rowFromRecord(record);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getOrganizationMemberEmailNotifications error:', error);
|
||||||
|
throw new Error('Failed to get organization member email notification preferences');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationMemberEmailNotificationsForApi(profileId: string, organizationId: string) {
|
||||||
|
const row = await getOrganizationMemberEmailNotifications(profileId, organizationId);
|
||||||
|
|
||||||
|
return memberEmailNotificationRowToApiResponse(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersonalEmailNotificationSettingsForOrg(
|
||||||
|
profileId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<PersonalEmailNotificationSettings | undefined> {
|
||||||
|
const row = await getOrganizationMemberEmailNotifications(profileId, organizationId);
|
||||||
|
|
||||||
|
return memberEmailNotificationRowToPersonalSettings(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertOrganizationMemberEmailNotifications(
|
||||||
|
profileId: string,
|
||||||
|
organizationId: string,
|
||||||
|
preferences: TMemberEmailNotificationPreferencesUpdate
|
||||||
|
): Promise<MemberEmailNotificationRow> {
|
||||||
|
try {
|
||||||
|
const organizationMemberId = await getOrganizationMemberIdByOrgAndProfile(organizationId, profileId);
|
||||||
|
|
||||||
|
if (!organizationMemberId) {
|
||||||
|
throw new Error('Organization member not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getOrganizationMemberEmailNotifications(profileId, organizationId);
|
||||||
|
const merged = personalSettingsToMemberEmailNotificationRow({
|
||||||
|
...(existing ?? DEFAULT_MEMBER_EMAIL_NOTIFICATION_ROW),
|
||||||
|
...preferences
|
||||||
|
});
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.insert(schema.organizationmemberEmailNotifications)
|
||||||
|
.values({
|
||||||
|
organizationMemberId,
|
||||||
|
...merged,
|
||||||
|
updatedAt
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.organizationmemberEmailNotifications.organizationMemberId,
|
||||||
|
set: {
|
||||||
|
...merged,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const row = rowFromRecord(record);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Failed to upsert organization member email notification preferences');
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('upsertOrganizationMemberEmailNotifications error:', error);
|
||||||
|
throw new Error('Failed to update organization member email notification preferences');
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-12
@@ -410,18 +410,7 @@ export const profile = pgTable(
|
|||||||
verifiedAt: timestamp('verified_at', { withTimezone: true, mode: 'string' }),
|
verifiedAt: timestamp('verified_at', { withTimezone: true, mode: 'string' }),
|
||||||
locale: locale().default('en'),
|
locale: locale().default('en'),
|
||||||
isRestricted: boolean('is_restricted').default(false).notNull(),
|
isRestricted: boolean('is_restricted').default(false).notNull(),
|
||||||
settings: jsonb().default({}).$type<{
|
settings: jsonb().default({}).$type<Record<string, unknown>>()
|
||||||
emailNotifications?: {
|
|
||||||
newStudent?: boolean;
|
|
||||||
newSubmission?: boolean;
|
|
||||||
gradingResult?: boolean;
|
|
||||||
newsfeed?: boolean;
|
|
||||||
quizAssigned?: boolean;
|
|
||||||
cohortReminder?: boolean;
|
|
||||||
session?: boolean;
|
|
||||||
courseCompletion?: boolean;
|
|
||||||
};
|
|
||||||
}>()
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
foreignKey({
|
foreignKey({
|
||||||
@@ -1860,6 +1849,30 @@ export const organizationmember = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const organizationmemberEmailNotifications = pgTable(
|
||||||
|
'organizationmember_email_notifications',
|
||||||
|
{
|
||||||
|
organizationMemberId: bigint('organization_member_id', { mode: 'number' }).primaryKey().notNull(),
|
||||||
|
newStudent: boolean('new_student').default(true).notNull(),
|
||||||
|
newSubmission: boolean('new_submission').default(true).notNull(),
|
||||||
|
gradingResult: boolean('grading_result').default(true).notNull(),
|
||||||
|
newsfeed: boolean('newsfeed').default(true).notNull(),
|
||||||
|
quizAssigned: boolean('quiz_assigned').default(true).notNull(),
|
||||||
|
cohortReminder: boolean('cohort_reminder').default(true).notNull(),
|
||||||
|
session: boolean('session').default(true).notNull(),
|
||||||
|
courseCompletion: boolean('course_completion').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.organizationMemberId],
|
||||||
|
foreignColumns: [organizationmember.id],
|
||||||
|
name: 'organizationmember_email_notifications_organization_member_id_fkey'
|
||||||
|
}).onDelete('cascade')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const question = pgTable(
|
export const question = pgTable(
|
||||||
'question',
|
'question',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Copies legacy profile.settings.emailNotifications into
|
||||||
|
* organizationmember_email_notifications (one row per membership), then
|
||||||
|
* removes the old jsonb key from profile.settings.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm db:backfill-member-email-notifications # dry run (default)
|
||||||
|
* pnpm db:backfill-member-email-notifications -- --execute # apply updates
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
|
||||||
|
const shouldExecute = process.argv.includes('--execute');
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('DATABASE_URL or PRIVATE_DATABASE_URL environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AFFECTED_MEMBERSHIPS_SQL = `
|
||||||
|
SELECT
|
||||||
|
om.id AS organization_member_id,
|
||||||
|
p.id AS profile_id,
|
||||||
|
om.organization_id,
|
||||||
|
p.settings->'emailNotifications' AS legacy_preferences
|
||||||
|
FROM organizationmember om
|
||||||
|
INNER JOIN profile p ON p.id = om.profile_id
|
||||||
|
WHERE p.settings ? 'emailNotifications'
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sql = postgres(connectionString);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const affected = await sql.unsafe(AFFECTED_MEMBERSHIPS_SQL);
|
||||||
|
console.log(`Found ${affected.length} organization membership(s) with legacy profile email preferences.`);
|
||||||
|
|
||||||
|
if (affected.length === 0) {
|
||||||
|
console.log('Nothing to do.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldExecute) {
|
||||||
|
console.log('Dry run only. Re-run with --execute to apply updates.');
|
||||||
|
console.log('Sample (up to 10):');
|
||||||
|
for (const row of affected.slice(0, 10)) {
|
||||||
|
console.log(` member=${row.organization_member_id} profile=${row.profile_id} org=${row.organization_id}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await sql`
|
||||||
|
INSERT INTO organizationmember_email_notifications (
|
||||||
|
organization_member_id,
|
||||||
|
new_student,
|
||||||
|
new_submission,
|
||||||
|
grading_result,
|
||||||
|
newsfeed,
|
||||||
|
quiz_assigned,
|
||||||
|
cohort_reminder,
|
||||||
|
session,
|
||||||
|
course_completion
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
om.id,
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'newStudent')::boolean, true),
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'newSubmission')::boolean, true),
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'gradingResult')::boolean, true),
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'newsfeed')::boolean, true),
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'quizAssigned')::boolean, true),
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'cohortReminder')::boolean, true),
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'session')::boolean, true),
|
||||||
|
COALESCE((p.settings->'emailNotifications'->>'courseCompletion')::boolean, true)
|
||||||
|
FROM organizationmember om
|
||||||
|
INNER JOIN profile p ON p.id = om.profile_id
|
||||||
|
WHERE p.settings ? 'emailNotifications'
|
||||||
|
ON CONFLICT (organization_member_id) DO NOTHING
|
||||||
|
RETURNING organization_member_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const clearedProfiles = await sql`
|
||||||
|
UPDATE profile
|
||||||
|
SET settings = settings - 'emailNotifications'
|
||||||
|
WHERE settings ? 'emailNotifications'
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const remaining = await sql.unsafe(`SELECT COUNT(*)::int AS count FROM (${AFFECTED_MEMBERSHIPS_SQL}) AS affected`);
|
||||||
|
const remainingCount = remaining[0]?.count ?? 0;
|
||||||
|
|
||||||
|
console.log(`Inserted ${inserted.length} organizationmember_email_notifications row(s).`);
|
||||||
|
console.log(`Cleared legacy preferences from ${clearedProfiles.length} profile(s).`);
|
||||||
|
console.log(`Remaining memberships with legacy profile preferences: ${remainingCount}`);
|
||||||
|
|
||||||
|
if (remainingCount > 0) {
|
||||||
|
console.warn('Some rows were not migrated — inspect manually before retrying.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Backfill complete.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('backfill-member-email-notifications-from-profile error:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -141,6 +141,8 @@ export type TNewLessonLanguageHistory = typeof schema.lessonLanguageHistory.$inf
|
|||||||
|
|
||||||
export type TOrganizationmember = typeof schema.organizationmember.$inferSelect;
|
export type TOrganizationmember = typeof schema.organizationmember.$inferSelect;
|
||||||
export type TNewOrganizationmember = typeof schema.organizationmember.$inferInsert;
|
export type TNewOrganizationmember = typeof schema.organizationmember.$inferInsert;
|
||||||
|
export type TOrganizationmemberEmailNotifications = typeof schema.organizationmemberEmailNotifications.$inferSelect;
|
||||||
|
export type TNewOrganizationmemberEmailNotifications = typeof schema.organizationmemberEmailNotifications.$inferInsert;
|
||||||
|
|
||||||
export type TQuestion = typeof schema.question.$inferSelect;
|
export type TQuestion = typeof schema.question.$inferSelect;
|
||||||
export type TNewQuestion = typeof schema.question.$inferInsert;
|
export type TNewQuestion = typeof schema.question.$inferInsert;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './email-toggles';
|
export * from './email-toggles';
|
||||||
|
export * from './member-email-notifications';
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { PersonalEmailNotificationSettings, PersonalEmailNotificationToggleKey } from './email-toggles';
|
||||||
|
import { PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS } from './email-toggles';
|
||||||
|
|
||||||
|
export type MemberEmailNotificationRow = {
|
||||||
|
newStudent: boolean;
|
||||||
|
newSubmission: boolean;
|
||||||
|
gradingResult: boolean;
|
||||||
|
newsfeed: boolean;
|
||||||
|
quizAssigned: boolean;
|
||||||
|
cohortReminder: boolean;
|
||||||
|
session: boolean;
|
||||||
|
courseCompletion: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MEMBER_EMAIL_NOTIFICATION_ROW: MemberEmailNotificationRow = {
|
||||||
|
newStudent: true,
|
||||||
|
newSubmission: true,
|
||||||
|
gradingResult: true,
|
||||||
|
newsfeed: true,
|
||||||
|
quizAssigned: true,
|
||||||
|
cohortReminder: true,
|
||||||
|
session: true,
|
||||||
|
courseCompletion: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export function memberEmailNotificationRowToPersonalSettings(
|
||||||
|
row: MemberEmailNotificationRow | null | undefined
|
||||||
|
): PersonalEmailNotificationSettings | undefined {
|
||||||
|
if (!row) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings: Partial<Record<PersonalEmailNotificationToggleKey, boolean>> = {};
|
||||||
|
|
||||||
|
for (const key of PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS) {
|
||||||
|
if (row[key] === false) {
|
||||||
|
settings[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalSettingsToMemberEmailNotificationRow(
|
||||||
|
preferences: Partial<PersonalEmailNotificationSettings> | MemberEmailNotificationRow
|
||||||
|
): MemberEmailNotificationRow {
|
||||||
|
return {
|
||||||
|
newStudent: preferences.newStudent ?? true,
|
||||||
|
newSubmission: preferences.newSubmission ?? true,
|
||||||
|
gradingResult: preferences.gradingResult ?? true,
|
||||||
|
newsfeed: preferences.newsfeed ?? true,
|
||||||
|
quizAssigned: preferences.quizAssigned ?? true,
|
||||||
|
cohortReminder: preferences.cohortReminder ?? true,
|
||||||
|
session: preferences.session ?? true,
|
||||||
|
courseCompletion: preferences.courseCompletion ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memberEmailNotificationRowToApiResponse(row: MemberEmailNotificationRow | null | undefined) {
|
||||||
|
return personalSettingsToMemberEmailNotificationRow(row ?? DEFAULT_MEMBER_EMAIL_NOTIFICATION_ROW);
|
||||||
|
}
|
||||||
@@ -1,22 +1,10 @@
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
const ZEmailNotificationToggleFields = {
|
|
||||||
newStudent: z.boolean().optional(),
|
|
||||||
newSubmission: z.boolean().optional(),
|
|
||||||
gradingResult: z.boolean().optional(),
|
|
||||||
newsfeed: z.boolean().optional(),
|
|
||||||
quizAssigned: z.boolean().optional(),
|
|
||||||
cohortReminder: z.boolean().optional(),
|
|
||||||
session: z.boolean().optional(),
|
|
||||||
courseCompletion: z.boolean().optional()
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ZUpdateProfile = z.object({
|
export const ZUpdateProfile = z.object({
|
||||||
fullname: z.string().min(3).optional(),
|
fullname: z.string().min(3).optional(),
|
||||||
username: z.string().min(3).optional(),
|
username: z.string().min(3).optional(),
|
||||||
locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']).optional(),
|
locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']).optional(),
|
||||||
avatarUrl: z.url().optional(),
|
avatarUrl: z.url().optional()
|
||||||
emailNotifications: z.object(ZEmailNotificationToggleFields).optional()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
|
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
export const ZPersonalEmailNotificationToggleFields = {
|
||||||
|
newStudent: z.boolean(),
|
||||||
|
newSubmission: z.boolean(),
|
||||||
|
gradingResult: z.boolean(),
|
||||||
|
newsfeed: z.boolean(),
|
||||||
|
quizAssigned: z.boolean(),
|
||||||
|
cohortReminder: z.boolean(),
|
||||||
|
session: z.boolean(),
|
||||||
|
courseCompletion: z.boolean()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZMemberEmailNotificationPreferences = z.object(ZPersonalEmailNotificationToggleFields);
|
||||||
|
|
||||||
|
export type TMemberEmailNotificationPreferences = z.infer<typeof ZMemberEmailNotificationPreferences>;
|
||||||
|
|
||||||
|
export const ZMemberEmailNotificationPreferencesUpdate = z.object(ZPersonalEmailNotificationToggleFields).partial();
|
||||||
|
|
||||||
|
export type TMemberEmailNotificationPreferencesUpdate = z.infer<typeof ZMemberEmailNotificationPreferencesUpdate>;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './email-notifications';
|
||||||
Reference in New Issue
Block a user