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 { autoJoinOrg } from '@api/services/organization/auto-join';
|
||||
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
||||
import { organizationMemberEmailNotificationsRouter } from '@api/routes/organization/member-email-notifications';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
@@ -768,4 +769,5 @@ export const organizationRouter = new Hono()
|
||||
.route('/widgets', widgetsRouter)
|
||||
.route('/assets', assetsRouter)
|
||||
.route('/ai-tutor', organizationAiTutorRouter)
|
||||
.route('/member/email-notifications', organizationMemberEmailNotificationsRouter)
|
||||
.route('/:orgId/quiz', quizRouter);
|
||||
|
||||
@@ -100,16 +100,7 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
|
||||
*/
|
||||
export async function updateUser(userId: string, data: TUpdateProfile) {
|
||||
try {
|
||||
const { emailNotifications, ...profileFields } = data;
|
||||
const updatePayload: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>> = {
|
||||
...profileFields
|
||||
};
|
||||
|
||||
if (emailNotifications !== undefined) {
|
||||
updatePayload.settings = { emailNotifications };
|
||||
}
|
||||
|
||||
const updatedProfile = await updateProfile(userId, updatePayload);
|
||||
const updatedProfile = await updateProfile(userId, data);
|
||||
|
||||
if (!updatedProfile) {
|
||||
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
|
||||
@@ -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) {
|
||||
// Validate email format
|
||||
const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail });
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CourseProgressPopover from './course-progress-popover.svelte';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||
@@ -85,6 +86,10 @@
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if $isStudentExperience}
|
||||
<CourseProgressPopover class="md:hidden" />
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
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 { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
|
||||
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 { courseApi, lessonApi } from '$features/course/api';
|
||||
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
|
||||
@@ -69,8 +70,10 @@
|
||||
const showMarkComplete = $derived(!!lessonId && !exerciseId);
|
||||
|
||||
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
|
||||
const isTeacherLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
|
||||
const isProgressionLocked = $derived($isOrgStudent && currentLessonItem?.accessible === false && !isTeacherLocked);
|
||||
const contentLockReason = $derived(
|
||||
lessonId ? getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson) : null
|
||||
);
|
||||
const isLessonLocked = $derived($isOrgStudent === true && contentLockReason !== null);
|
||||
const isVideoWatchLesson = $derived.by(() => {
|
||||
if (!lessonId) return false;
|
||||
|
||||
@@ -98,7 +101,6 @@
|
||||
|
||||
const watchVideosRequired = $derived(lessonApi.lesson?.watchProgress?.videosRequired ?? 0);
|
||||
const watchVideosComplete = $derived(lessonApi.lesson?.watchProgress?.videosComplete ?? 0);
|
||||
const isLessonLocked = $derived(isTeacherLocked || isProgressionLocked);
|
||||
const isVideoWatchComplete = $derived(isVideoWatchLesson && (lessonApi.lesson?.watchProgress?.isComplete ?? false));
|
||||
const showVideoWatchCompleteState = $derived(isVideoWatchComplete || (isVideoWatchLesson && isLessonComplete));
|
||||
const isMarkCompleteDisabled = $derived(
|
||||
|
||||
+18
-3
@@ -8,12 +8,13 @@
|
||||
import * as Collapsible from '@cio/ui/base/collapsible';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
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 { t } from '$lib/utils/functions/translations';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { RadioIcon } from '@cio/ui/custom/moving-icons';
|
||||
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
@@ -54,6 +55,18 @@
|
||||
<span class="flex-1 truncate">{section.title}</span>
|
||||
|
||||
<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}
|
||||
<IconButton
|
||||
variant="ghost-outline"
|
||||
@@ -85,7 +98,8 @@
|
||||
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
||||
{#snippet child({ props })}
|
||||
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
||||
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
|
||||
{@const isLockedForStudent =
|
||||
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
|
||||
<a
|
||||
href={resolve(getContentRoute(id, contentItem), {})}
|
||||
aria-disabled={isLockedForStudent}
|
||||
@@ -137,7 +151,8 @@
|
||||
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
||||
{#snippet child({ props })}
|
||||
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
||||
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
|
||||
{@const isLockedForStudent =
|
||||
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
|
||||
<a
|
||||
href={resolve(getContentRoute(id, contentItem), {})}
|
||||
aria-disabled={isLockedForStudent}
|
||||
|
||||
+30
-2
@@ -2,25 +2,53 @@
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { courseApi } from '$features/course/api';
|
||||
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 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>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
size="sm"
|
||||
size={showProgressRing ? 'lg' : 'sm'}
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
{#snippet child({ 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.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<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 PoweredBy from '$features/ui/powered-by.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { getCourseProgress } from '$features/course/utils/content';
|
||||
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 { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
|
||||
|
||||
@@ -45,6 +47,10 @@
|
||||
let stopSidebarResize: (() => void) | null = 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) {
|
||||
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
||||
@@ -114,7 +120,7 @@
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon" class="z-app-bar-elevated!" mobileOverlayClass="z-app-bar-elevated!">
|
||||
<Sidebar.Header>
|
||||
<CourseSidebarLogo />
|
||||
<CourseSidebarLogo isStudent={$isStudentExperience} />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
@@ -143,6 +149,12 @@
|
||||
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
|
||||
|
||||
<Sidebar.Footer>
|
||||
{#if showCourseProgress}
|
||||
<CourseProgressCard
|
||||
progress={courseProgress}
|
||||
class="ui:border-sidebar-border ui:bg-sidebar-accent/50 rounded-lg border p-3"
|
||||
/>
|
||||
{/if}
|
||||
<PoweredBy
|
||||
variant="sidebar"
|
||||
sidebarUtmSource="lms-course-sidebar"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
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 { snackbar } from '$features/ui/snackbar/store';
|
||||
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||
@@ -33,6 +34,7 @@
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
|
||||
import {
|
||||
@@ -83,17 +85,31 @@
|
||||
)
|
||||
);
|
||||
const lessonTitle = $derived(currentLessonContentItem?.title || lessonApi.lesson?.title || 'Lesson');
|
||||
const isTeacherLocked = $derived.by(() => {
|
||||
if ($isOrgStudent && currentLessonContentItem) {
|
||||
return (currentLessonContentItem.isUnlocked ?? false) === false;
|
||||
const contentLockReason = $derived(getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson));
|
||||
const isLessonLockedForStudent = $derived($isOrgStudent === true && contentLockReason !== null);
|
||||
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 isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const isLiveSessionLesson = $derived(Boolean(lessonApi.lesson?.callUrl && lessonApi.lesson?.lessonAt));
|
||||
@@ -450,7 +466,7 @@
|
||||
<Page.Body>
|
||||
{#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'}`}>
|
||||
{#if isLiveSessionLesson && mode === MODES.view && !($isOrgStudent && !isLessonUnlocked)}
|
||||
{#if isLiveSessionLesson && mode === MODES.view && !isLessonLockedForStudent}
|
||||
<div class="mb-4">
|
||||
<LiveSessionCard
|
||||
title={lessonTitle}
|
||||
@@ -461,11 +477,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $isOrgStudent && !isLessonUnlocked}
|
||||
<StudentContentLockedNotice
|
||||
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
||||
contentType={ContentType.Lesson}
|
||||
/>
|
||||
{#if $isOrgStudent === true && !isStudentLessonStateReady}
|
||||
<div class="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if isLessonLockedForStudent && contentLockReason}
|
||||
<StudentContentLockedNotice reason={contentLockReason} contentType={ContentType.Lesson} />
|
||||
{:else if mode === MODES.edit}
|
||||
<UnderlineTabs.Root
|
||||
bind:value={currentTabValue}
|
||||
@@ -541,6 +558,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{:else if $isOrgStudent === true}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.materials.no_content')}
|
||||
icon={VideoIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
/>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.materials.body_heading')}
|
||||
@@ -554,11 +578,9 @@
|
||||
variant="page"
|
||||
class="text-center"
|
||||
>
|
||||
{#if !$isOrgStudent}
|
||||
<Button onclick={toggleMode}>
|
||||
{$t('course.navItem.lessons.materials.get_started')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={toggleMode}>
|
||||
{$t('course.navItem.lessons.materials.get_started')}
|
||||
</Button>
|
||||
</Empty>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,42 @@ export function getCourseContent(course: Course | null) {
|
||||
|
||||
const NAVIGABLE_CONTENT_TYPES = [CourseContentType.Lesson, CourseContentType.Exercise] as const;
|
||||
|
||||
export type ContentProgress = {
|
||||
lessonsTotal: number;
|
||||
lessonsComplete: number;
|
||||
exercisesTotal: number;
|
||||
exercisesComplete: number;
|
||||
total: number;
|
||||
completed: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export function getContentItemsProgress(items: ContentItem[]): ContentProgress {
|
||||
const lessons = items.filter((item) => item.type === CourseContentType.Lesson);
|
||||
const exercises = items.filter((item) => item.type === CourseContentType.Exercise);
|
||||
const lessonsComplete = lessons.filter((item) => item.isComplete).length;
|
||||
const exercisesComplete = exercises.filter((item) => item.isComplete).length;
|
||||
const total = lessons.length + exercises.length;
|
||||
const completed = lessonsComplete + exercisesComplete;
|
||||
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
|
||||
|
||||
return {
|
||||
lessonsTotal: lessons.length,
|
||||
lessonsComplete,
|
||||
exercisesTotal: exercises.length,
|
||||
exercisesComplete,
|
||||
total,
|
||||
completed,
|
||||
percent
|
||||
};
|
||||
}
|
||||
|
||||
export function getCourseProgress(course: Course | null): ContentProgress {
|
||||
const content = getCourseContent(course);
|
||||
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
|
||||
return getContentItemsProgress(items);
|
||||
}
|
||||
|
||||
export function getOrderedNavigableContent(course: Course | null): ContentItem[] {
|
||||
const content = getCourseContent(course);
|
||||
const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items;
|
||||
|
||||
@@ -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,
|
||||
TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS
|
||||
} from '@cio/utils/notifications';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { profileApi } from '$features/auth/api/profile.svelte';
|
||||
import { memberEmailNotificationsApi } from '$features/settings/api/member-email-notifications.svelte';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
@@ -26,10 +25,12 @@
|
||||
}
|
||||
|
||||
let personalToggles = $state(buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS));
|
||||
let savedPersonalToggles = $state(buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS));
|
||||
let orgToggles = $state(buildToggleState(EMAIL_NOTIFICATION_TOGGLE_KEYS));
|
||||
let isSavingPersonal = $state(false);
|
||||
let isSavingOrg = $state(false);
|
||||
let hasHydratedFromStores = $state(false);
|
||||
let isLoadingPersonal = $state(false);
|
||||
let hasHydratedOrgToggles = $state(false);
|
||||
|
||||
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
|
||||
|
||||
@@ -42,12 +43,18 @@
|
||||
: PERSONAL_EMAIL_NOTIFICATION_SECTIONS
|
||||
);
|
||||
|
||||
function syncPersonalTogglesFromProfile() {
|
||||
const profileState = get(profile);
|
||||
const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, profileState.settings?.emailNotifications);
|
||||
const personalDescription = $derived(
|
||||
$currentOrg?.name
|
||||
? t.get('settings.notifications.personal.description_with_org', { orgName: $currentOrg.name })
|
||||
: t.get('settings.notifications.personal.description')
|
||||
);
|
||||
|
||||
function applyPersonalToggleState(source?: Partial<Record<string, boolean>> | null) {
|
||||
const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, source);
|
||||
|
||||
for (const key of PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS) {
|
||||
personalToggles[key] = next[key];
|
||||
savedPersonalToggles[key] = next[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,41 +70,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
function tryHydrateFromStores() {
|
||||
if (hasHydratedFromStores) return;
|
||||
async function loadPersonalPreferences() {
|
||||
if (!$currentOrg?.id) return;
|
||||
|
||||
const profileState = get(profile);
|
||||
if (!profileState.id) return;
|
||||
isLoadingPersonal = true;
|
||||
|
||||
syncPersonalTogglesFromProfile();
|
||||
const result = await memberEmailNotificationsApi.fetch();
|
||||
|
||||
if (get(isOrgAdmin)) {
|
||||
if (!get(currentOrg)?.id) return;
|
||||
syncOrgTogglesFromCurrentOrg();
|
||||
if (result?.data) {
|
||||
applyPersonalToggleState(result.data);
|
||||
}
|
||||
|
||||
hasHydratedFromStores = true;
|
||||
isLoadingPersonal = false;
|
||||
}
|
||||
|
||||
function tryHydrateOrgToggles() {
|
||||
if (hasHydratedOrgToggles || !get(isOrgAdmin)) return;
|
||||
|
||||
if (!get(currentOrg)?.id) return;
|
||||
|
||||
syncOrgTogglesFromCurrentOrg();
|
||||
hasHydratedOrgToggles = true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
tryHydrateFromStores();
|
||||
tryHydrateOrgToggles();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (hasHydratedFromStores) return;
|
||||
const orgId = $currentOrg?.id;
|
||||
|
||||
const profileId = $profile.id;
|
||||
const orgId = $isOrgAdmin ? $currentOrg?.id : 'ready';
|
||||
if (!orgId) return;
|
||||
|
||||
if (!profileId || ($isOrgAdmin && !orgId)) return;
|
||||
void loadPersonalPreferences();
|
||||
});
|
||||
|
||||
tryHydrateFromStores();
|
||||
$effect(() => {
|
||||
if (hasHydratedOrgToggles) return;
|
||||
|
||||
const orgId = $isOrgAdmin ? $currentOrg?.id : null;
|
||||
|
||||
if (!orgId) return;
|
||||
|
||||
tryHydrateOrgToggles();
|
||||
});
|
||||
|
||||
const personalHasChanges = $derived(
|
||||
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some(
|
||||
(key) => personalToggles[key] !== ($profile.settings?.emailNotifications?.[key] !== false)
|
||||
)
|
||||
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some((key) => personalToggles[key] !== savedPersonalToggles[key])
|
||||
);
|
||||
|
||||
const orgHasChanges = $derived(
|
||||
@@ -117,14 +136,16 @@
|
||||
}
|
||||
|
||||
async function handleSavePersonal() {
|
||||
if (!$currentOrg?.id) return;
|
||||
|
||||
isSavingPersonal = true;
|
||||
|
||||
await profileApi.updateEmailNotifications(
|
||||
const result = await memberEmailNotificationsApi.update(
|
||||
toStoredPreferences(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, personalToggles)
|
||||
);
|
||||
|
||||
if (profileApi.success) {
|
||||
syncPersonalTogglesFromProfile();
|
||||
if (memberEmailNotificationsApi.success && result?.data) {
|
||||
applyPersonalToggleState(result.data);
|
||||
snackbar.success(t.get('settings.notifications.personal.save_success'));
|
||||
}
|
||||
|
||||
@@ -164,7 +185,7 @@
|
||||
{$t('settings.notifications.personal.heading')}
|
||||
</Field.Legend>
|
||||
<Field.Description>
|
||||
{$t('settings.notifications.personal.description')}
|
||||
{personalDescription}
|
||||
</Field.Description>
|
||||
|
||||
<div class="mt-4 space-y-6">
|
||||
@@ -182,7 +203,7 @@
|
||||
<div class="mt-4 space-y-4">
|
||||
{#each section.keys as key (key)}
|
||||
<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">
|
||||
<Field.Label>{$t(`settings.notifications.toggles.${key}.label`)}</Field.Label>
|
||||
<Field.Description>
|
||||
@@ -200,7 +221,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={isSavingPersonal}
|
||||
disabled={!personalHasChanges || isSavingPersonal}
|
||||
disabled={!personalHasChanges || isSavingPersonal || isLoadingPersonal || !$currentOrg?.id}
|
||||
onclick={handleSavePersonal}
|
||||
>
|
||||
{$t('settings.notifications.personal.save')}
|
||||
|
||||
@@ -16,3 +16,14 @@ export type AiUsageData = GetUsageSuccess['data'];
|
||||
export type PurchasedSummaryData = GetPurchasedSuccess['data'];
|
||||
export type LeaderboardData = GetLeaderboardSuccess['data'];
|
||||
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',
|
||||
path: '/settings'
|
||||
},
|
||||
{
|
||||
titleKey: 'Notifications',
|
||||
path: '/settings/notifications'
|
||||
},
|
||||
{
|
||||
titleKey: '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",
|
||||
"due_date": "Forfalder",
|
||||
"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": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "Hold",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "AI underviser",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "Notifikationer"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Åbent Akademi"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "Notifikationer",
|
||||
"page_title": "Notifikationer",
|
||||
"page_subtitle": "Vælg hvilke e-mailnotifikationer du modtager, og som administrator hvilke e-mails din organisation sender.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Kursusafslutning og certifikater",
|
||||
"description": "Send e-mail til studerende, når de gennemfører et kursus og opnår et certifikat."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "Velkomst-e-mails ved tilmelding",
|
||||
"description": "Send velkomst-e-mails, når personalet tilmelder studerende til kurser eller kohorter."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Påmindelser og opdateringer om livesessioner",
|
||||
"description": "Send e-mail til studerende før livesessioner og når sessionsdetaljer ændres."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Påmindelser om kohortemål",
|
||||
"description": "Send e-mail til studerende om kommende eller overskredne kohortlæringsmål."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Øvelse tildelt",
|
||||
"description": "Send e-mail til studerende, når en øvelse tildeles eller påmindes."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Kursusmeddelelser og kommentarer",
|
||||
"description": "Send e-mail til kursusmedlemmer om nye opslag og kommentarer på nyhedsfeedet."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Bedømmelsesresultater",
|
||||
"description": "Send e-mail til studerende, når deres aflevering er bedømt eller opdateret."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Nye afleveringer til bedømmelse",
|
||||
"description": "Send e-mail til undervisere, når en studerende afleverer arbejde, der skal bedømmes."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Ny studerende tilmelder sig",
|
||||
"description": "Send e-mail til undervisere, når en studerende tilmelder sig et kursus."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Organisationsstandarder",
|
||||
"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": "Gem organisationsstandarder",
|
||||
"save_success": "Organisationsnotifikationsstandarder gemt"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Dine e-mailpræferencer",
|
||||
"save": "Gem personlige præferencer",
|
||||
"save_success": "Personlige notifikationspræferencer gemt",
|
||||
"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": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Tilmelding",
|
||||
"description": "Velkomst-e-mails sendt, når personalet tilføjer medlemmer til kurser eller kohorter."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Kohorte",
|
||||
"description": "Påmindelser om kohortens læringsmål og deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Kursus",
|
||||
"description": "Tilmeldinger, afleveringer, lektioner, meddelelser og gennemførelser i dine kurser."
|
||||
}
|
||||
}
|
||||
},
|
||||
"personal_page_subtitle": "Vælg hvilke e-mailnotifikationer du modtager fra ClassroomIO."
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "Compliance",
|
||||
"due_date": "Fällig",
|
||||
"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": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "Mannschaften",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "KI-Tutor",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "Benachrichtigungen"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Offene Akademie"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "Benachrichtigungen",
|
||||
"page_title": "Benachrichtigungen",
|
||||
"page_subtitle": "Wählen Sie, welche E-Mail-Benachrichtigungen Sie erhalten, und als Administrator, welche E-Mails Ihre Organisation sendet.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Kursabschluss und Zertifikate",
|
||||
"description": "E-Mail an Studenten senden, wenn sie einen Kurs abschließen und ein Zertifikat erhalten."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "Willkommens-E-Mails bei Anmeldung",
|
||||
"description": "Willkommens-E-Mails senden, wenn Mitarbeitende Studenten zu Kursen oder Kohorten anmelden."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Erinnerungen und Updates zu Live-Sitzungen",
|
||||
"description": "E-Mail an Studenten vor Live-Sitzungen und bei Änderungen der Sitzungsdetails senden."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Erinnerungen an Kohortenlernziele",
|
||||
"description": "E-Mail an Studenten über bevorstehende oder überfällige Kohortenlernziele senden."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Übung zugewiesen",
|
||||
"description": "E-Mail an Studenten senden, wenn eine Übung zugewiesen oder erinnert wird."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Kursankündigungen und Kommentare",
|
||||
"description": "E-Mail an Kursmitglieder über neue Beiträge und Kommentare im Newsfeed senden."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Bewertungsergebnisse",
|
||||
"description": "E-Mail an Studenten senden, wenn ihre Abgabe bewertet oder aktualisiert wird."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Neue Abgaben zum Bewerten",
|
||||
"description": "E-Mail an Tutoren senden, wenn ein Student Arbeit zur Bewertung einreicht."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Neuer Student meldet sich an",
|
||||
"description": "E-Mail an Tutoren senden, wenn sich ein Student für einen Kurs anmeldet."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Organisationsstandards",
|
||||
"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": "Organisationsstandards speichern",
|
||||
"save_success": "Organisations-Benachrichtigungsstandards gespeichert"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Ihre E-Mail-Einstellungen",
|
||||
"save": "Persönliche Einstellungen speichern",
|
||||
"save_success": "Persönliche Benachrichtigungseinstellungen gespeichert",
|
||||
"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": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Anmeldung",
|
||||
"description": "Willkommens-E-Mails, die gesendet werden, wenn Mitarbeitende Mitglieder zu Kursen oder Kohorten hinzufügen."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Kohorte",
|
||||
"description": "Erinnerungen an Lernziele und Fristen der Kohorte."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Kurs",
|
||||
"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": {
|
||||
|
||||
@@ -440,9 +440,11 @@
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"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": {
|
||||
"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_success": "Personal notification preferences saved"
|
||||
},
|
||||
@@ -2003,6 +2005,12 @@
|
||||
"content_count": {
|
||||
"lessons": "{count} lessons",
|
||||
"exercises": "{count} exercises"
|
||||
},
|
||||
"progress": {
|
||||
"title": "Your progress",
|
||||
"completed": "{completed} of {total} completed",
|
||||
"lessons": "{completed} of {total} lessons",
|
||||
"exercises": "{completed} of {total} exercises"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "Cumplimiento",
|
||||
"due_date": "debido",
|
||||
"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": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "equipos",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Tutor de IA",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "Notificaciones"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Academia Abierta"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "Notificaciones",
|
||||
"page_title": "Notificaciones",
|
||||
"page_subtitle": "Elige qué notificaciones por correo recibes y, como administrador, qué correos envía tu organización.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Finalización del curso y certificados",
|
||||
"description": "Enviar correo a estudiantes cuando completan un curso y obtienen un certificado."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "Correos de bienvenida por inscripción",
|
||||
"description": "Enviar correos de bienvenida cuando el personal inscribe estudiantes en cursos o cohortes."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Recordatorios y actualizaciones de sesiones en vivo",
|
||||
"description": "Enviar correo a estudiantes antes de sesiones en vivo y cuando cambian los detalles."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Recordatorios de objetivos de cohorte",
|
||||
"description": "Enviar correo a estudiantes sobre objetivos de aprendizaje próximos o vencidos."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Ejercicio asignado",
|
||||
"description": "Enviar correo a estudiantes cuando se asigna o recuerda un ejercicio."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Anuncios y comentarios del curso",
|
||||
"description": "Enviar correo a los miembros del curso sobre nuevas publicaciones y comentarios en el feed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Resultados de calificación",
|
||||
"description": "Enviar correo a estudiantes cuando su entrega es calificada o actualizada."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Nuevas entregas para calificar",
|
||||
"description": "Enviar correo a tutores cuando un estudiante entrega trabajo que necesita calificación."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Nuevo estudiante se inscribe",
|
||||
"description": "Enviar correo a tutores cuando un estudiante se inscribe en un curso."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Valores predeterminados de la organización",
|
||||
"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": "Guardar valores predeterminados de la organización",
|
||||
"save_success": "Valores predeterminados de notificación de la organización guardados"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Tus preferencias de correo",
|
||||
"save": "Guardar preferencias personales",
|
||||
"save_success": "Preferencias de notificación personales guardadas",
|
||||
"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": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Inscripción",
|
||||
"description": "Correos de bienvenida enviados cuando el personal añade miembros a cursos o cohortes."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Cohorte",
|
||||
"description": "Recordatorios sobre objetivos de aprendizaje y plazos de la cohorte."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Curso",
|
||||
"description": "Inscripciones, entregas, lecciones, anuncios y finalizaciones en tus cursos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"personal_page_subtitle": "Elige qué notificaciones por correo recibes de ClassroomIO."
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "Conformité",
|
||||
"due_date": "Due",
|
||||
"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": {
|
||||
@@ -3641,71 +3647,73 @@
|
||||
"notifications": {
|
||||
"heading": "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": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Achèvement du cours et certificats",
|
||||
"description": "Envoyer un e-mail aux étudiants lorsqu'ils terminent un cours et obtiennent un certificat."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "E-mails de bienvenue d'inscription",
|
||||
"description": "Envoyer des e-mails de bienvenue lorsque le personnel inscrit des étudiants à des cours ou cohortes."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Rappels et mises à jour des sessions en direct",
|
||||
"description": "Envoyer un e-mail aux étudiants avant les sessions en direct et lors des changements de détails."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Rappels d'objectifs de cohorte",
|
||||
"description": "Envoyer un e-mail aux étudiants concernant les objectifs d'apprentissage à venir ou en retard."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Exercice assigné",
|
||||
"description": "Envoyer un e-mail aux étudiants lorsqu'un exercice est assigné ou rappelé."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Annonces et commentaires du cours",
|
||||
"description": "Envoyer un e-mail aux membres du cours concernant les nouvelles publications et commentaires."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Résultats de notation",
|
||||
"description": "Envoyer un e-mail aux étudiants lorsque leur soumission est notée ou mise à jour."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Nouvelles soumissions à noter",
|
||||
"description": "Envoyer un e-mail aux tuteurs lorsqu'un étudiant soumet un travail à noter."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Nouvel étudiant s'inscrit",
|
||||
"description": "Envoyer un e-mail aux tuteurs lorsqu'un étudiant s'inscrit à un cours."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Paramètres par défaut de l'organisation",
|
||||
"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": "Enregistrer les paramètres par défaut de l'organisation",
|
||||
"save_success": "Paramètres de notification de l'organisation enregistrés"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Vos préférences e-mail",
|
||||
"save": "Enregistrer les préférences personnelles",
|
||||
"save_success": "Préférences de notification personnelles enregistrées",
|
||||
"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": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Inscription",
|
||||
"description": "E-mails de bienvenue envoyés lorsque le personnel ajoute des membres à des cours ou cohortes."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Cohorte",
|
||||
"description": "Rappels concernant les objectifs d'apprentissage et les échéances de la cohorte."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Cours",
|
||||
"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": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "अनुपालन",
|
||||
"due_date": "देय",
|
||||
"valid_until": "तक वैध है"
|
||||
},
|
||||
"progress": {
|
||||
"title": "आपकी प्रगति",
|
||||
"completed": "{total} में से {completed} पूर्ण",
|
||||
"lessons": "{total} में से {completed} पाठ",
|
||||
"exercises": "{total} में से {completed} अभ्यास"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "टीमें",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "एआई ट्यूटर",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "सूचनाएँ"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "अकादमी खोलें"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "सूचनाएँ",
|
||||
"page_title": "सूचनाएँ",
|
||||
"page_subtitle": "चुनें कि आप कौन सी ईमेल सूचनाएँ प्राप्त करते हैं और व्यवस्थापक के रूप में आपका संगठन कौन से ईमेल भेजता है।",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "पाठ्यक्रम पूर्णता और प्रमाणपत्र",
|
||||
"description": "जब छात्र पाठ्यक्रम पूरा करें और प्रमाणपत्र प्राप्त करें तो ईमेल भेजें।"
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "नामांकन स्वागत ईमेल",
|
||||
"description": "जब स्टाफ छात्रों को पाठ्यक्रमों या समूहों में नामांकित करे तो स्वागत ईमेल भेजें।"
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "लाइव सत्र अनुस्मारक और अपडेट",
|
||||
"description": "लाइव सत्रों से पहले और सत्र विवरण बदलने पर छात्रों को ईमेल भेजें।"
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "समूह लक्ष्य अनुस्मारक",
|
||||
"description": "आगामी या अतिदेय समूह सीखने के लक्ष्यों के बारे में छात्रों को ईमेल भेजें।"
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "अभ्यास असाइन किया गया",
|
||||
"description": "जब अभ्यास असाइन या याद दिलाया जाए तो छात्रों को ईमेल भेजें।"
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "पाठ्यक्रम घोषणाएँ और टिप्पणियाँ",
|
||||
"description": "न्यूज़फ़ीड पर नई पोस्ट और टिप्पणियों के बारे में पाठ्यक्रम सदस्यों को ईमेल भेजें।"
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "ग्रेडिंग परिणाम",
|
||||
"description": "जब उनका सबमिशन ग्रेड या अपडेट हो तो छात्रों को ईमेल भेजें।"
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "ग्रेडिंग के लिए नए सबमिशन",
|
||||
"description": "जब छात्र ग्रेडिंग की आवश्यकता वाला कार्य जमा करे तो ट्यूटर्स को ईमेल भेजें।"
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "नया छात्र शामिल होता है",
|
||||
"description": "जब कोई छात्र पाठ्यक्रम में नामांकित हो तो ट्यूटर्स को ईमेल भेजें।"
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "संगठन डिफ़ॉल्ट",
|
||||
"description": "यहाँ अक्षम करने पर, आपके संगठन में कोई भी वह ईमेल नहीं प्राप्त करेगा। सदस्य अभी भी उन ईमेल से ऑप्ट आउट कर सकते हैं जिन्हें आप सक्षम रखते हैं।",
|
||||
"save": "संगठन डिफ़ॉल्ट सहेजें",
|
||||
"save_success": "संगठन सूचना डिफ़ॉल्ट सहेजे गए"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "आपकी ईमेल प्राथमिकताएँ",
|
||||
"save": "व्यक्तिगत प्राथमिकताएँ सहेजें",
|
||||
"save_success": "व्यक्तिगत सूचना प्राथमिकताएँ सहेजी गईं",
|
||||
"description": "ये उन ईमेल पर लागू होते हैं जो इस संगठन में आपको भेजे जाते हैं। आपका संगठन फिर भी कुछ ईमेल सभी के लिए बंद कर सकता है।",
|
||||
"description_with_org": "ये उन ईमेल पर लागू होते हैं जो {orgName} में आपको भेजे जाते हैं। आपका संगठन फिर भी कुछ ईमेल सभी के लिए बंद कर सकता है।"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "नामांकन",
|
||||
"description": "स्वागत ईमेल जब स्टाफ सदस्यों को पाठ्यक्रमों या समूहों में जोड़ता है।"
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "समूह",
|
||||
"description": "समूह सीखने के लक्ष्यों और समय सीमा के बारे में अनुस्मारक।"
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "पाठ्यक्रम",
|
||||
"description": "आपके पाठ्यक्रमों में नामांकन, सबमिशन, पाठ, घोषणाएँ और पूर्णता।"
|
||||
}
|
||||
}
|
||||
},
|
||||
"personal_page_subtitle": "चुनें कि आप ClassroomIO से कौन सी ईमेल सूचनाएँ प्राप्त करते हैं।"
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "Zgodność",
|
||||
"due_date": "Termin",
|
||||
"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": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "Zespoły",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Nauczyciel AI",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "Powiadomienia"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Otwarta Akademia"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "Powiadomienia",
|
||||
"page_title": "Powiadomienia",
|
||||
"page_subtitle": "Wybierz, które powiadomienia e-mail otrzymujesz, a jako administrator, które e-maile wysyła Twoja organizacja.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Ukończenie kursu i certyfikaty",
|
||||
"description": "Wyślij e-mail do studentów, gdy ukończą kurs i otrzymają certyfikat."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "Powitalne e-maile zapisów",
|
||||
"description": "Wysyłaj e-maile powitalne, gdy personel zapisuje studentów na kursy lub kohorty."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Przypomnienia i aktualizacje sesji na żywo",
|
||||
"description": "Wyślij e-mail do studentów przed sesjami na żywo i gdy zmienią się szczegóły sesji."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Przypomnienia o celach kohorty",
|
||||
"description": "Wyślij e-mail do studentów o nadchodzących lub zaległych celach nauki kohorty."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Przypisane ćwiczenie",
|
||||
"description": "Wyślij e-mail do studentów, gdy ćwiczenie zostanie przypisane lub przypomniane."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Ogłoszenia kursu i komentarze",
|
||||
"description": "Wyślij e-mail do członków kursu o nowych postach i komentarzach w aktualnościach."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Wyniki oceniania",
|
||||
"description": "Wyślij e-mail do studentów, gdy ich zgłoszenie zostanie ocenione lub zaktualizowane."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Nowe zgłoszenia do oceny",
|
||||
"description": "Wyślij e-mail do tutorów, gdy student prześle pracę wymagającą oceny."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Nowy student dołącza",
|
||||
"description": "Wyślij e-mail do tutorów, gdy student zapisze się na kurs."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Domyślne ustawienia organizacji",
|
||||
"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": "Zapisz domyślne ustawienia organizacji",
|
||||
"save_success": "Domyślne ustawienia powiadomień organizacji zapisane"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Twoje preferencje e-mail",
|
||||
"save": "Zapisz preferencje osobiste",
|
||||
"save_success": "Osobiste preferencje powiadomień zapisane",
|
||||
"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": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Zapisy",
|
||||
"description": "E-maile powitalne wysyłane, gdy personel dodaje członków do kursów lub kohort."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Kohorta",
|
||||
"description": "Przypomnienia o celach nauki kohorty i terminach."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Kurs",
|
||||
"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": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "Conformidade",
|
||||
"due_date": "Devido",
|
||||
"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": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "Equipes",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Tutor de IA",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "Notificações"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Academia Aberta"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "Notificações",
|
||||
"page_title": "Notificações",
|
||||
"page_subtitle": "Escolha quais notificações por e-mail você recebe e, como administrador, quais e-mails sua organização envia.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Conclusão do curso e certificados",
|
||||
"description": "Enviar e-mail aos alunos quando concluírem um curso e receberem um certificado."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "E-mails de boas-vindas de matrícula",
|
||||
"description": "Enviar e-mails de boas-vindas quando a equipe matricular alunos em cursos ou coortes."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Lembretes e atualizações de sessões ao vivo",
|
||||
"description": "Enviar e-mail aos alunos antes de sessões ao vivo e quando os detalhes mudarem."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Lembretes de metas da coorte",
|
||||
"description": "Enviar e-mail aos alunos sobre metas de aprendizagem próximas ou atrasadas."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Exercício atribuído",
|
||||
"description": "Enviar e-mail aos alunos quando um exercício for atribuído ou lembrado."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Anúncios e comentários do curso",
|
||||
"description": "Enviar e-mail aos membros do curso sobre novas publicações e comentários no feed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Resultados de avaliação",
|
||||
"description": "Enviar e-mail aos alunos quando o envio for avaliado ou atualizado."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Novos envios para avaliar",
|
||||
"description": "Enviar e-mail aos tutores quando um aluno envia trabalho que precisa de avaliação."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Novo aluno se inscreve",
|
||||
"description": "Enviar e-mail aos tutores quando um aluno se matricula em um curso."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Padrões da organização",
|
||||
"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": "Salvar padrões da organização",
|
||||
"save_success": "Padrões de notificação da organização salvos"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Suas preferências de e-mail",
|
||||
"save": "Salvar preferências pessoais",
|
||||
"save_success": "Preferências de notificação pessoais salvas",
|
||||
"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": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Matrícula",
|
||||
"description": "E-mails de boas-vindas enviados quando a equipe adiciona membros a cursos ou coortes."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Coorte",
|
||||
"description": "Lembretes sobre metas de aprendizagem e prazos da coorte."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Curso",
|
||||
"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": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "Соответствие",
|
||||
"due_date": "Срок погашения",
|
||||
"valid_until": "Действительно до"
|
||||
},
|
||||
"progress": {
|
||||
"title": "Ваш прогресс",
|
||||
"completed": "Завершено {completed} из {total}",
|
||||
"lessons": "{completed} из {total} уроков",
|
||||
"exercises": "{completed} из {total} упражнений"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "Команды",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Репетитор по искусственному интеллекту",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "Уведомления"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Открытая Академия"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "Уведомления",
|
||||
"page_title": "Уведомления",
|
||||
"page_subtitle": "Выберите, какие уведомления по электронной почте вы получаете, а как администратор — какие письма отправляет ваша организация.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Завершение курса и сертификаты",
|
||||
"description": "Отправлять письмо студентам, когда они завершают курс и получают сертификат."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "Приветственные письма при записи",
|
||||
"description": "Отправлять приветственные письма, когда сотрудники записывают студентов на курсы или когорты."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Напоминания и обновления живых сессий",
|
||||
"description": "Отправлять письмо студентам перед живыми сессиями и при изменении деталей сессии."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Напоминания о целях когорты",
|
||||
"description": "Отправлять письмо студентам о предстоящих или просроченных целях обучения когорты."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Назначено упражнение",
|
||||
"description": "Отправлять письмо студентам, когда упражнение назначено или напоминается."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Объявления курса и комментарии",
|
||||
"description": "Отправлять письмо участникам курса о новых публикациях и комментариях в ленте."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Результаты оценки",
|
||||
"description": "Отправлять письмо студентам, когда их работа оценена или обновлена."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Новые работы для оценки",
|
||||
"description": "Отправлять письмо наставникам, когда студент сдаёт работу, требующую оценки."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Новый студент записывается",
|
||||
"description": "Отправлять письмо наставникам, когда студент записывается на курс."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Настройки организации по умолчанию",
|
||||
"description": "Если отключено здесь, никто в вашей организации не получит это письмо. Участники по-прежнему могут отказаться от писем, которые вы оставили включёнными.",
|
||||
"save": "Сохранить настройки организации по умолчанию",
|
||||
"save_success": "Настройки уведомлений организации сохранены"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Ваши настройки электронной почты",
|
||||
"save": "Сохранить личные настройки",
|
||||
"save_success": "Личные настройки уведомлений сохранены",
|
||||
"description": "Они применяются к письмам, которые вам отправляют в этой организации. Организация по-прежнему может отключить некоторые письма для всех.",
|
||||
"description_with_org": "Они применяются к письмам, которые вам отправляют в {orgName}. Организация по-прежнему может отключить некоторые письма для всех."
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Запись",
|
||||
"description": "Приветственные письма, отправляемые, когда сотрудники добавляют участников в курсы или когорты."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Когорта",
|
||||
"description": "Напоминания о целях обучения когорты и сроках."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Курс",
|
||||
"description": "Записи, сдачи, уроки, объявления и завершения в ваших курсах."
|
||||
}
|
||||
}
|
||||
},
|
||||
"personal_page_subtitle": "Выберите, какие уведомления по электронной почте вы получаете от ClassroomIO."
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -2370,6 +2370,12 @@
|
||||
"title": "Tuân thủ",
|
||||
"due_date": "Đến hạn",
|
||||
"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": {
|
||||
@@ -3463,7 +3469,7 @@
|
||||
"teams_tab": "Đội",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Gia sư AI",
|
||||
"notifications_tab": "Notifications"
|
||||
"notifications_tab": "Thông báo"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Học viện mở"
|
||||
@@ -3639,73 +3645,75 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"heading": "Thông báo",
|
||||
"page_title": "Thông báo",
|
||||
"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": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
"label": "Hoàn thành khóa học và chứng chỉ",
|
||||
"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": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
"label": "Email chào mừng ghi danh",
|
||||
"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": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
"label": "Nhắc nhở và cập nhật buổi học trực tiếp",
|
||||
"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": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
"label": "Nhắc nhở mục tiêu nhóm học",
|
||||
"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": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
"label": "Bài tập được giao",
|
||||
"description": "Gửi email cho học viên khi bài tập được giao hoặc nhắc nhở."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
"label": "Thông báo và bình luận khóa học",
|
||||
"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": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
"label": "Kết quả chấm điểm",
|
||||
"description": "Gửi email cho học viên khi bài nộp được chấm hoặc cập nhật."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
"label": "Bài nộp mới cần chấm",
|
||||
"description": "Gửi email cho gia sư khi học viên nộp bài cần chấm điểm."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
"label": "Học viên mới tham gia",
|
||||
"description": "Gửi email cho gia sư khi học viên ghi danh vào khóa học."
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"heading": "Organization defaults",
|
||||
"description": "When disabled here, nobody in your organization receives that email. Members can still opt out of emails you leave enabled.",
|
||||
"save": "Save organization defaults",
|
||||
"save_success": "Organization notification defaults saved"
|
||||
"heading": "Mặc định của tổ chức",
|
||||
"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": "Lưu mặc định của tổ chức",
|
||||
"save_success": "Đã lưu mặc định thông báo của tổ chức"
|
||||
},
|
||||
"personal": {
|
||||
"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.",
|
||||
"save": "Save personal preferences",
|
||||
"save_success": "Personal notification preferences saved"
|
||||
"heading": "Tùy chọn email của bạn",
|
||||
"save": "Lưu tùy chọn cá nhân",
|
||||
"save_success": "Đã lưu tùy chọn thông báo cá nhân",
|
||||
"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": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
"heading": "Ghi danh",
|
||||
"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": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
"heading": "Nhóm học",
|
||||
"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": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
"heading": "Khóa học",
|
||||
"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": {
|
||||
|
||||
@@ -16,7 +16,16 @@
|
||||
let { data }: Props = $props();
|
||||
|
||||
$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;
|
||||
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",
|
||||
"organization",
|
||||
"student-dashboard",
|
||||
|
||||
"---Enterprise---",
|
||||
"enterprise-sso",
|
||||
"token-auth",
|
||||
|
||||
"---Get started---",
|
||||
"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: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-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:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.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,
|
||||
"tag": "0003_profile_settings",
|
||||
"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
|
||||
} from '@cio/utils/notifications';
|
||||
|
||||
import { getProfileByEmail, getProfileById } from '../auth/profile';
|
||||
import { db } from '@db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getOrganizationEmailNotificationSettings } from './email-preferences-org';
|
||||
import { getPersonalEmailNotificationSettingsForOrg } from './organizationmember-email-notifications';
|
||||
import { getProfileByEmail } from '../auth/profile';
|
||||
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: {
|
||||
emailId: EmailId;
|
||||
organizationId?: string;
|
||||
@@ -67,7 +23,7 @@ export async function shouldSendEmail(input: {
|
||||
*/
|
||||
export class EmailPreferenceLookupCache {
|
||||
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>();
|
||||
|
||||
async shouldSend(input: {
|
||||
@@ -84,7 +40,8 @@ export class EmailPreferenceLookupCache {
|
||||
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);
|
||||
}
|
||||
@@ -107,12 +64,17 @@ export class EmailPreferenceLookupCache {
|
||||
return this.profileIdByEmail.get(email);
|
||||
}
|
||||
|
||||
private async getProfileSettings(profileId: string): Promise<PersonalEmailNotificationSettings | undefined> {
|
||||
if (!this.profileSettingsById.has(profileId)) {
|
||||
const settings = await getProfileEmailNotificationSettingsById(profileId);
|
||||
this.profileSettingsById.set(profileId, settings);
|
||||
private async getPersonalSettings(
|
||||
profileId: string,
|
||||
organizationId: string
|
||||
): 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-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' }),
|
||||
locale: locale().default('en'),
|
||||
isRestricted: boolean('is_restricted').default(false).notNull(),
|
||||
settings: jsonb().default({}).$type<{
|
||||
emailNotifications?: {
|
||||
newStudent?: boolean;
|
||||
newSubmission?: boolean;
|
||||
gradingResult?: boolean;
|
||||
newsfeed?: boolean;
|
||||
quizAssigned?: boolean;
|
||||
cohortReminder?: boolean;
|
||||
session?: boolean;
|
||||
courseCompletion?: boolean;
|
||||
};
|
||||
}>()
|
||||
settings: jsonb().default({}).$type<Record<string, unknown>>()
|
||||
},
|
||||
(table) => [
|
||||
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(
|
||||
'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 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 TNewQuestion = typeof schema.question.$inferInsert;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
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';
|
||||
|
||||
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({
|
||||
fullname: 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(),
|
||||
avatarUrl: z.url().optional(),
|
||||
emailNotifications: z.object(ZEmailNotificationToggleFields).optional()
|
||||
avatarUrl: z.url().optional()
|
||||
});
|
||||
|
||||
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