feat(notifications): per-org member email preferences table (#751)
* 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 <[email protected]> * 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 <[email protected]> * fix(notifications): use Exercise instead of Quiz in toggle copy Co-authored-by: Rotimi Best <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * feat(notifications): add email preferences to LMS settings Students manage personal email opt-outs from /lms/settings/notifications, matching the org settings page but scoped to the LMS navigation students use. Co-authored-by: Rotimi Best <[email protected]> * feat(notifications): store per-org personal prefs in organizationmember_email_notifications - Add organizationmember_email_notifications table keyed by organizationmember - Expose GET/PUT /organization/member/email-notifications for current org member - Resolve personal email prefs by profileId + organizationId at send time - Migrate legacy profile.settings.emailNotifications into per-membership rows - Update org and LMS settings UI to load/save org-scoped member preferences Co-authored-by: Rotimi Best <[email protected]> * fix(notifications): address PR review on locales and backfill script Move profile email-notification backfill out of the migration SQL into a dedicated db script with dry-run support. Translate personal preference description strings in all non-English locale files. Co-authored-by: Rotimi Best <[email protected]> * fix(i18n): translate notification preference strings in all locales Complete settings.notifications and settings.tabs.notifications_tab translations for da, de, es, fr, hi, pl, pt, ru, and vi. Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
9681770270
commit
f3d1a885b9
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user