feat(notifications): email notification preferences (#747)
* feat(notifications): add email notification preferences Implement the email notification preferences PRD with org-level defaults and personal opt-outs enforced at the email enqueue chokepoint. - Add toggle map, profile.settings jsonb, and shouldSendEmail resolver - Extend profile and organization update APIs with emailNotifications - Pass preference context through all toggleable email send call sites - Add Settings > Notifications page with personal and org admin sections Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(notifications): group email toggles into course, cohort, and enrollment sections Organize personal and org notification settings into scannable sections so users can find course- or cohort-related emails quickly. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): use Exercise instead of Quiz in toggle copy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): only mark certification email sent when enqueued Skip setMemberCertificationEmailSent when notification preferences or missing Redis prevent the completion email from being queued, so the email can still send if preferences change later. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): address PR review feedback - Add EmailPreferenceLookupCache to avoid N+1 settings lookups in batch processors and multi-recipient email enqueue paths - Replace $effect store hydration in notifications settings with onMount and explicit sync after save - Remove thin api email-preferences re-export; import from db queries - Hide tutor-only personal toggles (newStudent, newSubmission) for students Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): hydrate toggles after store load and add profile settings migration - One-time store hydration via onMount plus guarded effect when profile/org load after mount, without clobbering in-progress edits - Add drizzle migration for profile.settings jsonb column Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> ---------
This commit is contained in:
co-authored by
Rotimi Best
parent
14db5363e7
commit
fa060f5f6d
@@ -17,6 +17,7 @@ import {
|
||||
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TProfile } from '@cio/db/types';
|
||||
import type { TUpdateProfile } from '@cio/utils/validation/account';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { getLicenseStatus } from '@api/services/license';
|
||||
|
||||
@@ -97,12 +98,18 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
|
||||
* @param data - Partial profile data to update (excluding email, id, createdAt, updatedAt)
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
export async function updateUser(
|
||||
userId: string,
|
||||
data: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>>
|
||||
) {
|
||||
export async function updateUser(userId: string, data: TUpdateProfile) {
|
||||
try {
|
||||
const updatedProfile = await updateProfile(userId, data);
|
||||
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);
|
||||
|
||||
if (!updatedProfile) {
|
||||
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
|
||||
@@ -429,7 +429,8 @@ export async function runCohortGoalReminderScan(): Promise<{
|
||||
theme: row.organizationTheme
|
||||
})
|
||||
},
|
||||
idempotencyKey: `cohort-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
|
||||
idempotencyKey: `cohort-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`,
|
||||
preference: { organizationId: row.organizationId, recipientProfileId: row.profileId }
|
||||
});
|
||||
remindersEnqueued += 1;
|
||||
} catch (error) {
|
||||
|
||||
@@ -359,6 +359,7 @@ async function sendStudentJoinEmails(input: {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
orgName: string;
|
||||
organizationId: string;
|
||||
org: OrgUrlInfo;
|
||||
branding: EmailBranding;
|
||||
studentId: string;
|
||||
@@ -379,7 +380,8 @@ async function sendStudentJoinEmails(input: {
|
||||
},
|
||||
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`,
|
||||
ics
|
||||
ics,
|
||||
preference: { organizationId: input.organizationId, recipientProfileId: input.studentId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue student welcome email:', error);
|
||||
@@ -407,7 +409,8 @@ async function sendStudentJoinEmails(input: {
|
||||
branding: input.branding
|
||||
},
|
||||
from: buildEmailFromName('ClassroomIO'),
|
||||
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
|
||||
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`,
|
||||
preference: { organizationId: input.organizationId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue teacher join notification emails:', error);
|
||||
@@ -696,6 +699,7 @@ export async function enrollInCourse(
|
||||
courseId,
|
||||
courseName: title,
|
||||
orgName: org.name,
|
||||
organizationId: org.id,
|
||||
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
|
||||
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }),
|
||||
studentId: user.id,
|
||||
@@ -964,6 +968,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name,
|
||||
organizationId: organization.id,
|
||||
orgSiteName: organization.siteName,
|
||||
orgCustomDomain: organization.customDomain,
|
||||
orgIsCustomDomainVerified: organization.isCustomDomainVerified,
|
||||
@@ -1049,6 +1054,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name,
|
||||
organizationId: organization.id,
|
||||
orgSiteName: organization.siteName,
|
||||
orgCustomDomain: organization.customDomain,
|
||||
orgIsCustomDomainVerified: organization.isCustomDomainVerified,
|
||||
@@ -1066,6 +1072,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: result.courseId,
|
||||
courseName: result.courseName,
|
||||
orgName: result.orgName,
|
||||
organizationId: result.organizationId,
|
||||
org: {
|
||||
siteName: result.orgSiteName,
|
||||
customDomain: result.orgCustomDomain,
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function notifyCourseExerciseService(courseId: string, exerciseId:
|
||||
exerciseTitle: exercise.title,
|
||||
courseName: course.title,
|
||||
orgName,
|
||||
organizationId: orgData.orgId,
|
||||
fromName: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
quizUrl,
|
||||
branding: buildEmailBranding({
|
||||
|
||||
@@ -110,7 +110,8 @@ export async function addMember(
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`,
|
||||
ics: await getWelcomeSessionIcs(courseId)
|
||||
ics: await getWelcomeSessionIcs(courseId),
|
||||
preference: { organizationId: courseOrgData.orgId, recipientProfileId: data.profileId }
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to enqueue student welcome email:', emailError);
|
||||
@@ -133,7 +134,8 @@ export async function addMember(
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName('ClassroomIO'),
|
||||
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
|
||||
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`,
|
||||
preference: { organizationId: courseOrgData.orgId }
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to enqueue teacher notification email:', emailError);
|
||||
@@ -237,7 +239,8 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
|
||||
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`,
|
||||
preference: { organizationId: courseOrgData.orgId }
|
||||
}).catch((emailError) => {
|
||||
console.error(`Failed to enqueue welcome email to ${email}:`, emailError);
|
||||
return [];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { enqueueEmailSend, isRedisConfigured } from '@cio/jobs';
|
||||
import { EmailRegistry, type EmailId, type EmailSchemaFor } from '@cio/email';
|
||||
import { EmailPreferenceLookupCache } from '@cio/db/queries/notifications';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
|
||||
@@ -24,6 +25,10 @@ export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOp
|
||||
subject?: string;
|
||||
/** Optional iCalendar (.ics) body attached as a text/calendar part. */
|
||||
ics?: string;
|
||||
preference?: {
|
||||
organizationId?: string;
|
||||
recipientProfileId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnqueueRawEmailInput extends CommonOptions {
|
||||
@@ -76,8 +81,22 @@ export async function enqueueTransactionalEmail<TId extends EmailId>(
|
||||
|
||||
const recipients = toRecipientArray(input.to);
|
||||
const jobIds: string[] = [];
|
||||
const preferenceCache = input.preference ? new EmailPreferenceLookupCache() : null;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (preferenceCache && input.preference) {
|
||||
const allowed = await preferenceCache.shouldSend({
|
||||
emailId: template,
|
||||
organizationId: input.preference.organizationId,
|
||||
recipientEmail: recipient,
|
||||
recipientProfileId: input.preference.recipientProfileId
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const jobId = await enqueueEmailSend(
|
||||
{
|
||||
kind: 'template',
|
||||
|
||||
@@ -384,7 +384,8 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
||||
replyTo: feedData.author?.email || 'noreply@classroomio.com',
|
||||
idempotencyKey: `newsfeed:post:${feedId}`
|
||||
idempotencyKey: `newsfeed:post:${feedId}`,
|
||||
preference: { organizationId: feedData.organization.id }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending newsfeed post email:', error);
|
||||
@@ -426,7 +427,8 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
||||
replyTo: 'noreply@classroomio.com'
|
||||
replyTo: 'noreply@classroomio.com',
|
||||
preference: { organizationId: feedData.organization.id }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending newsfeed comment email:', error);
|
||||
|
||||
@@ -224,7 +224,8 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`,
|
||||
ics: icsByGroupId.get(p.groupId)
|
||||
ics: icsByGroupId.get(p.groupId),
|
||||
preference: { organizationId: orgId, recipientProfileId: p.profileId }
|
||||
});
|
||||
emailsSent++;
|
||||
} catch (emailError) {
|
||||
@@ -307,7 +308,8 @@ async function enrollAudienceStudentProfilesInCohorts(
|
||||
branding: buildEmailBranding(organization)
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `audience-cohort-welcome:${pair.cohortId}:${pair.profileId}`
|
||||
idempotencyKey: `audience-cohort-welcome:${pair.cohortId}:${pair.profileId}`,
|
||||
preference: { organizationId: orgId, recipientProfileId: pair.profileId }
|
||||
});
|
||||
emailsSent++;
|
||||
} catch (emailError) {
|
||||
|
||||
@@ -1005,7 +1005,11 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
|
||||
})
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
|
||||
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`,
|
||||
preference: {
|
||||
organizationId: orgResult?.orgId,
|
||||
recipientProfileId: fullSubmission.groupmember.profile.id
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue submission graded email:', error);
|
||||
@@ -1076,7 +1080,8 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
|
||||
})
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
|
||||
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`,
|
||||
preference: { organizationId: orgResult?.orgId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue submission received email:', error);
|
||||
|
||||
@@ -74,7 +74,7 @@ export function scheduleCertificationCompletionWork(params: {
|
||||
});
|
||||
const certificateUrl = `${base}/courses/${courseId}/certificates`;
|
||||
|
||||
await enqueueTransactionalEmail('studentCourseCompletion', {
|
||||
const enqueueResult = await enqueueTransactionalEmail('studentCourseCompletion', {
|
||||
to: studentEmail,
|
||||
fields: {
|
||||
orgName: courseRow.orgName,
|
||||
@@ -89,9 +89,14 @@ export function scheduleCertificationCompletionWork(params: {
|
||||
})
|
||||
},
|
||||
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `course-completion:${groupMemberId}`
|
||||
idempotencyKey: `course-completion:${groupMemberId}`,
|
||||
preference: { organizationId: courseRow.orgId, recipientProfileId: profileId }
|
||||
});
|
||||
|
||||
if (enqueueResult.jobIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
|
||||
} catch (error) {
|
||||
console.error('scheduleCertificationCompletionWork: failed:', error);
|
||||
|
||||
@@ -137,6 +137,30 @@ 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 });
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface TOrgUpdateForm {
|
||||
disableSignupMessage?: string;
|
||||
disableEmailPassword?: boolean;
|
||||
disableGoogleAuth?: boolean;
|
||||
settings?: { signup?: { inviteOnly?: boolean } };
|
||||
settings?: { signup?: { inviteOnly?: boolean }; emailNotifications?: Record<string, boolean> };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ export { default as BillingPage } from './billing.svelte';
|
||||
export { default as AiCreditsPage } from './ai-credits.svelte';
|
||||
export { default as IntegrationsPage } from './integrations.svelte';
|
||||
export { default as AuthPage } from './auth.svelte';
|
||||
export { default as NotificationsPage } from './notifications.svelte';
|
||||
// Keep old exports for backward compatibility
|
||||
export { default as SsoPage } from './sso.svelte';
|
||||
export { default as TokenAuthPage } from './token-auth.svelte';
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
EMAIL_NOTIFICATION_TOGGLE_KEYS,
|
||||
ORG_EMAIL_NOTIFICATION_SECTIONS,
|
||||
PERSONAL_EMAIL_NOTIFICATION_SECTIONS,
|
||||
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 { orgApi } from '$features/org/api/org.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import Building2Icon from '@lucide/svelte/icons/building-2';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
function buildToggleState<T extends string>(keys: readonly T[], source?: Partial<Record<T, boolean>> | null) {
|
||||
return Object.fromEntries(keys.map((key) => [key, source?.[key] !== false])) as Record<T, boolean>;
|
||||
}
|
||||
|
||||
let personalToggles = $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);
|
||||
|
||||
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
|
||||
|
||||
const personalNotificationSections = $derived(
|
||||
$isOrgStudent
|
||||
? PERSONAL_EMAIL_NOTIFICATION_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
keys: section.keys.filter((key) => !tutorOnlyToggleKeys.has(key))
|
||||
})).filter((section) => section.keys.length > 0)
|
||||
: PERSONAL_EMAIL_NOTIFICATION_SECTIONS
|
||||
);
|
||||
|
||||
function syncPersonalTogglesFromProfile() {
|
||||
const profileState = get(profile);
|
||||
const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, profileState.settings?.emailNotifications);
|
||||
|
||||
for (const key of PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS) {
|
||||
personalToggles[key] = next[key];
|
||||
}
|
||||
}
|
||||
|
||||
function syncOrgTogglesFromCurrentOrg() {
|
||||
const org = get(currentOrg);
|
||||
|
||||
if (!org) return;
|
||||
|
||||
const next = buildToggleState(EMAIL_NOTIFICATION_TOGGLE_KEYS, org.settings?.emailNotifications);
|
||||
|
||||
for (const key of EMAIL_NOTIFICATION_TOGGLE_KEYS) {
|
||||
orgToggles[key] = next[key];
|
||||
}
|
||||
}
|
||||
|
||||
function tryHydrateFromStores() {
|
||||
if (hasHydratedFromStores) return;
|
||||
|
||||
const profileState = get(profile);
|
||||
if (!profileState.id) return;
|
||||
|
||||
syncPersonalTogglesFromProfile();
|
||||
|
||||
if (get(isOrgAdmin)) {
|
||||
if (!get(currentOrg)?.id) return;
|
||||
syncOrgTogglesFromCurrentOrg();
|
||||
}
|
||||
|
||||
hasHydratedFromStores = true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
tryHydrateFromStores();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (hasHydratedFromStores) return;
|
||||
|
||||
const profileId = $profile.id;
|
||||
const orgId = $isOrgAdmin ? $currentOrg?.id : 'ready';
|
||||
|
||||
if (!profileId || ($isOrgAdmin && !orgId)) return;
|
||||
|
||||
tryHydrateFromStores();
|
||||
});
|
||||
|
||||
const personalHasChanges = $derived(
|
||||
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some(
|
||||
(key) => personalToggles[key] !== ($profile.settings?.emailNotifications?.[key] !== false)
|
||||
)
|
||||
);
|
||||
|
||||
const orgHasChanges = $derived(
|
||||
EMAIL_NOTIFICATION_TOGGLE_KEYS.some(
|
||||
(key) => orgToggles[key] !== ($currentOrg?.settings?.emailNotifications?.[key] !== false)
|
||||
)
|
||||
);
|
||||
|
||||
function toStoredPreferences<T extends string>(keys: readonly T[], toggles: Record<T, boolean>) {
|
||||
const preferences: Partial<Record<T, boolean>> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
preferences[key] = toggles[key];
|
||||
}
|
||||
|
||||
return preferences;
|
||||
}
|
||||
|
||||
async function handleSavePersonal() {
|
||||
isSavingPersonal = true;
|
||||
|
||||
await profileApi.updateEmailNotifications(
|
||||
toStoredPreferences(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, personalToggles)
|
||||
);
|
||||
|
||||
if (profileApi.success) {
|
||||
syncPersonalTogglesFromProfile();
|
||||
snackbar.success(t.get('settings.notifications.personal.save_success'));
|
||||
}
|
||||
|
||||
isSavingPersonal = false;
|
||||
}
|
||||
|
||||
async function handleSaveOrg() {
|
||||
if (!$currentOrg) return;
|
||||
|
||||
isSavingOrg = true;
|
||||
const existingSettings = ($currentOrg.settings as Record<string, unknown>) || {};
|
||||
|
||||
await orgApi.update(
|
||||
$currentOrg.id,
|
||||
{
|
||||
settings: {
|
||||
...existingSettings,
|
||||
emailNotifications: toStoredPreferences(EMAIL_NOTIFICATION_TOGGLE_KEYS, orgToggles)
|
||||
}
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
syncOrgTogglesFromCurrentOrg();
|
||||
snackbar.success(t.get('settings.notifications.org.save_success'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
isSavingOrg = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="w-full max-w-2xl! px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<BellIcon class="size-5" />
|
||||
{$t('settings.notifications.personal.heading')}
|
||||
</Field.Legend>
|
||||
<Field.Description>
|
||||
{$t('settings.notifications.personal.description')}
|
||||
</Field.Description>
|
||||
|
||||
<div class="mt-4 space-y-6">
|
||||
{#each personalNotificationSections as section, sectionIndex (section.id)}
|
||||
{#if sectionIndex > 0}
|
||||
<Field.Separator />
|
||||
{/if}
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t(`settings.notifications.sections.${section.id}.heading`)}</Field.Legend>
|
||||
<Field.Description>
|
||||
{$t(`settings.notifications.sections.${section.id}.description`)}
|
||||
</Field.Description>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
{#each section.keys as key (key)}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={personalToggles[key]} disabled={isSavingPersonal} />
|
||||
<div class="space-y-0.5">
|
||||
<Field.Label>{$t(`settings.notifications.toggles.${key}.label`)}</Field.Label>
|
||||
<Field.Description>
|
||||
{$t(`settings.notifications.toggles.${key}.description`)}
|
||||
</Field.Description>
|
||||
</div>
|
||||
</Field.Field>
|
||||
{/each}
|
||||
</div>
|
||||
</Field.Set>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={isSavingPersonal}
|
||||
disabled={!personalHasChanges || isSavingPersonal}
|
||||
onclick={handleSavePersonal}
|
||||
>
|
||||
{$t('settings.notifications.personal.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Field.Set>
|
||||
|
||||
{#if $isOrgAdmin}
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<Building2Icon class="size-5" />
|
||||
{$t('settings.notifications.org.heading')}
|
||||
</Field.Legend>
|
||||
<Field.Description>
|
||||
{$t('settings.notifications.org.description')}
|
||||
</Field.Description>
|
||||
|
||||
<div class="mt-4 space-y-6">
|
||||
{#each ORG_EMAIL_NOTIFICATION_SECTIONS as section, sectionIndex (section.id)}
|
||||
{#if sectionIndex > 0}
|
||||
<Field.Separator />
|
||||
{/if}
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t(`settings.notifications.sections.${section.id}.heading`)}</Field.Legend>
|
||||
<Field.Description>
|
||||
{$t(`settings.notifications.sections.${section.id}.description`)}
|
||||
</Field.Description>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
{#each section.keys as key (key)}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={orgToggles[key]} disabled={isSavingOrg} />
|
||||
<div class="space-y-0.5">
|
||||
<Field.Label>{$t(`settings.notifications.toggles.${key}.label`)}</Field.Label>
|
||||
<Field.Description>
|
||||
{$t(`settings.notifications.toggles.${key}.description`)}
|
||||
</Field.Description>
|
||||
</div>
|
||||
</Field.Field>
|
||||
{/each}
|
||||
</div>
|
||||
</Field.Set>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={isSavingOrg}
|
||||
disabled={!orgHasChanges || isSavingOrg}
|
||||
onclick={handleSaveOrg}
|
||||
>
|
||||
{$t('settings.notifications.org.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Field.Set>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
@@ -1,5 +1,9 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type UpdateProfileRequest = typeof classroomio.account.profile.$put;
|
||||
export type UpdateProfileSuccess = Extract<InferResponseType<UpdateProfileRequest>, { success: true }>;
|
||||
export type UpdateProfileResponseProfile = UpdateProfileSuccess['profile'];
|
||||
|
||||
export type GetUsageRequest = typeof classroomio.agent.usage.$get;
|
||||
export type GetPurchasedRequest = (typeof classroomio.agent.usage)['purchased']['$get'];
|
||||
export type GetLeaderboardRequest = (typeof classroomio.agent.usage)['leaderboard']['$get'];
|
||||
|
||||
@@ -210,6 +210,11 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
path: '/settings',
|
||||
matchPattern: '^/org/[^/]+/settings/?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.tabs.notifications_tab',
|
||||
path: '/settings/notifications',
|
||||
matchPattern: '^/org/[^/]+/settings/notifications/?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.tabs.organization_tab',
|
||||
path: '/settings/org',
|
||||
@@ -253,6 +258,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
}
|
||||
],
|
||||
nestedRoutes: [
|
||||
{
|
||||
path: 'notifications',
|
||||
titleKey: 'settings.tabs.notifications_tab'
|
||||
},
|
||||
{
|
||||
path: 'billing',
|
||||
titleKey: 'settings.tabs.billing_tab'
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Domæner",
|
||||
"teams_tab": "Hold",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "AI underviser"
|
||||
"ai_tutor_tab": "AI underviser",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Åbent Akademi"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Køb for ${dollars}"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Domänen",
|
||||
"teams_tab": "Mannschaften",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "KI-Tutor"
|
||||
"ai_tutor_tab": "KI-Tutor",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Offene Akademie"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Für ${dollars} kaufen"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -398,6 +398,7 @@
|
||||
},
|
||||
"tabs": {
|
||||
"profile_tab": "Profile",
|
||||
"notifications_tab": "Notifications",
|
||||
"organization_tab": "Organization",
|
||||
"landing_page_tab": "Landing Page",
|
||||
"billing_tab": "Billing",
|
||||
@@ -435,6 +436,75 @@
|
||||
"update_profile": "Update Profile",
|
||||
"logout": "Log out"
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
}
|
||||
},
|
||||
"toggles": {
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
}
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
"page_subtitle": "Set the name, logo, and theme students see for this organization.",
|
||||
"organization_profile": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Dominios",
|
||||
"teams_tab": "equipos",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Tutor de IA"
|
||||
"ai_tutor_tab": "Tutor de IA",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Academia Abierta"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Comprar por ${dollars}"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Domaines",
|
||||
"teams_tab": "Équipes",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Tuteur IA"
|
||||
"ai_tutor_tab": "Tuteur IA",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Académie ouverte"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Acheter pour ${dollars}"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "डोमेन",
|
||||
"teams_tab": "टीमें",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "एआई ट्यूटर"
|
||||
"ai_tutor_tab": "एआई ट्यूटर",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "अकादमी खोलें"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "${dollars} में खरीदें"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Domeny",
|
||||
"teams_tab": "Zespoły",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Nauczyciel AI"
|
||||
"ai_tutor_tab": "Nauczyciel AI",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Otwarta Akademia"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Kup za ${dollars}"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Domínios",
|
||||
"teams_tab": "Equipes",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Tutor de IA"
|
||||
"ai_tutor_tab": "Tutor de IA",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Academia Aberta"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Comprar por ${dollars}"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Домены",
|
||||
"teams_tab": "Команды",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Репетитор по искусственному интеллекту"
|
||||
"ai_tutor_tab": "Репетитор по искусственному интеллекту",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Открытая Академия"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Купить за ${dollars}"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -3395,7 +3395,8 @@
|
||||
"domains_tab": "Tên miền",
|
||||
"teams_tab": "Đội",
|
||||
"ai_credits_tab": "AI Credits",
|
||||
"ai_tutor_tab": "Gia sư AI"
|
||||
"ai_tutor_tab": "Gia sư AI",
|
||||
"notifications_tab": "Notifications"
|
||||
},
|
||||
"subheadings": {
|
||||
"view_site": "Học viện mở"
|
||||
@@ -3569,6 +3570,75 @@
|
||||
"preview": "{quantity} pack(s) · {tokens} credits · ${dollars}",
|
||||
"buy_button": "Mua với ${dollars}"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications",
|
||||
"page_title": "Notifications",
|
||||
"page_subtitle": "Choose which email notifications you receive and, as an admin, which emails your organization sends.",
|
||||
"toggles": {
|
||||
"courseCompletion": {
|
||||
"label": "Course completion and certificates",
|
||||
"description": "Email students when they complete a course and earn a certificate."
|
||||
},
|
||||
"enrollmentWelcome": {
|
||||
"label": "Enrollment welcome emails",
|
||||
"description": "Send welcome emails when staff enroll students in courses or cohorts."
|
||||
},
|
||||
"session": {
|
||||
"label": "Live session reminders and updates",
|
||||
"description": "Email students before live sessions and when session details change."
|
||||
},
|
||||
"cohortReminder": {
|
||||
"label": "Cohort goal reminders",
|
||||
"description": "Email students about upcoming or overdue cohort learning goals."
|
||||
},
|
||||
"quizAssigned": {
|
||||
"label": "Exercise assigned",
|
||||
"description": "Email students when an exercise is assigned or nudged."
|
||||
},
|
||||
"newsfeed": {
|
||||
"label": "Course announcements and comments",
|
||||
"description": "Email course members about new posts and comments on the newsfeed."
|
||||
},
|
||||
"gradingResult": {
|
||||
"label": "Grading results",
|
||||
"description": "Email students when their submission is graded or updated."
|
||||
},
|
||||
"newSubmission": {
|
||||
"label": "New submissions to grade",
|
||||
"description": "Email tutors when a student submits work that needs grading."
|
||||
},
|
||||
"newStudent": {
|
||||
"label": "New student joins",
|
||||
"description": "Email tutors when a student enrolls in a course."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
"enrollment": {
|
||||
"heading": "Enrollment",
|
||||
"description": "Welcome emails sent when staff add members to courses or cohorts."
|
||||
},
|
||||
"cohort": {
|
||||
"heading": "Cohort",
|
||||
"description": "Reminders about cohort learning goals and deadlines."
|
||||
},
|
||||
"course": {
|
||||
"heading": "Course",
|
||||
"description": "Enrollments, submissions, lessons, announcements, and completions in your courses."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenu": {
|
||||
|
||||
@@ -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.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<NotificationsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getCourseMembers } from '@cio/db/queries/course/people';
|
||||
import { EmailPreferenceLookupCache } from '@cio/db/queries/notifications';
|
||||
import { ZNotifyCourseExercisePayload, enqueueEmailSend } from '@cio/jobs';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
@@ -20,17 +21,33 @@ export async function processNotifyCourseExercise(rawPayload: unknown, jobId: st
|
||||
const members = await getCourseMembers(payload.courseId);
|
||||
const recipients = members
|
||||
.filter((member) => member.roleId === ROLE.STUDENT)
|
||||
.map((member) => member.profile?.email)
|
||||
.filter((email): email is string => !!email);
|
||||
.map((member) => ({
|
||||
email: member.profile?.email,
|
||||
profileId: member.profile?.id
|
||||
}))
|
||||
.filter((recipient): recipient is { email: string; profileId: string | undefined } => !!recipient.email);
|
||||
|
||||
let notified = 0;
|
||||
for (const email of recipients) {
|
||||
const preferenceCache = new EmailPreferenceLookupCache();
|
||||
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
const allowed = await preferenceCache.shouldSend({
|
||||
emailId: 'quizAssigned',
|
||||
organizationId: payload.organizationId,
|
||||
recipientEmail: recipient.email,
|
||||
recipientProfileId: recipient.profileId
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await enqueueEmailSend(
|
||||
{
|
||||
kind: 'template',
|
||||
template: 'quizAssigned',
|
||||
to: email,
|
||||
to: recipient.email,
|
||||
fields: {
|
||||
orgName: payload.orgName,
|
||||
courseName: payload.courseName,
|
||||
@@ -41,11 +58,11 @@ export async function processNotifyCourseExercise(rawPayload: unknown, jobId: st
|
||||
from: payload.fromName
|
||||
},
|
||||
// Vary by the notify job id so re-running the nudge re-sends.
|
||||
{ idempotencyKey: `quiz-assigned:${jobId}:${email}` }
|
||||
{ idempotencyKey: `quiz-assigned:${jobId}:${recipient.email}` }
|
||||
);
|
||||
notified += 1;
|
||||
} catch (error) {
|
||||
log.error('notify-course-exercise-enqueue-failed', { jobId, recipient: email });
|
||||
log.error('notify-course-exercise-enqueue-failed', { jobId, recipient: recipient.email });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCourseById, getCourseWithOrgData } from '@cio/db/queries/course';
|
||||
import { getCourseMembers } from '@cio/db/queries/course/people';
|
||||
import { getLessonById } from '@cio/db/queries/lesson';
|
||||
import { EmailPreferenceLookupCache } from '@cio/db/queries/notifications';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { buildEmailBranding, buildEmailFromName, buildSessionIcs } from '@cio/email';
|
||||
import { ZNotifyCourseSessionUpdatePayload, enqueueEmailSend } from '@cio/jobs';
|
||||
@@ -68,17 +69,33 @@ export async function processNotifyCourseSessionUpdate(rawPayload: unknown): Pro
|
||||
const members = await getCourseMembers(courseId);
|
||||
const recipients = members
|
||||
.filter((member) => member.roleId === ROLE.STUDENT)
|
||||
.map((member) => member.profile?.email)
|
||||
.filter((email): email is string => !!email);
|
||||
.map((member) => ({
|
||||
email: member.profile?.email,
|
||||
profileId: member.profile?.id
|
||||
}))
|
||||
.filter((recipient): recipient is { email: string; profileId: string | undefined } => !!recipient.email);
|
||||
|
||||
let notified = 0;
|
||||
for (const email of recipients) {
|
||||
const preferenceCache = new EmailPreferenceLookupCache();
|
||||
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
const allowed = await preferenceCache.shouldSend({
|
||||
emailId: 'sessionUpdated',
|
||||
organizationId: orgData.orgId,
|
||||
recipientEmail: recipient.email,
|
||||
recipientProfileId: recipient.profileId
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await enqueueEmailSend(
|
||||
{
|
||||
kind: 'template',
|
||||
template: 'sessionUpdated',
|
||||
to: email,
|
||||
to: recipient.email,
|
||||
fields: {
|
||||
orgName,
|
||||
courseName: course.title,
|
||||
@@ -90,11 +107,11 @@ export async function processNotifyCourseSessionUpdate(rawPayload: unknown): Pro
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
ics
|
||||
},
|
||||
{ idempotencyKey: `session-updated:${lesson.id}:${email}:${sequence}` }
|
||||
{ idempotencyKey: `session-updated:${lesson.id}:${recipient.email}:${sequence}` }
|
||||
);
|
||||
notified += 1;
|
||||
} catch (error) {
|
||||
log.error('notify-session-update-enqueue-failed', { lessonId, recipient: email });
|
||||
log.error('notify-session-update-enqueue-failed', { lessonId, recipient: recipient.email });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { listUpcomingSessionsForReminderScan } from '@cio/db/queries/course';
|
||||
import { EmailPreferenceLookupCache } from '@cio/db/queries/notifications';
|
||||
import { buildEmailBranding, buildEmailFromName, buildSessionIcs } from '@cio/email';
|
||||
import { enqueueEmailSend } from '@cio/jobs';
|
||||
|
||||
@@ -34,6 +35,7 @@ export async function processSessionReminderScan(): Promise<ScanResult> {
|
||||
const now = Date.now();
|
||||
|
||||
let remindersEnqueued = 0;
|
||||
const preferenceCache = new EmailPreferenceLookupCache();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.email) continue;
|
||||
@@ -70,6 +72,17 @@ export async function processSessionReminderScan(): Promise<ScanResult> {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const allowed = await preferenceCache.shouldSend({
|
||||
emailId: 'sessionReminder',
|
||||
organizationId: row.organizationId,
|
||||
recipientEmail: row.email,
|
||||
recipientProfileId: row.profileId
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await enqueueEmailSend(
|
||||
{
|
||||
kind: 'template',
|
||||
|
||||
Reference in New Issue
Block a user