* 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]> ---------
168 lines
5.1 KiB
TypeScript
168 lines
5.1 KiB
TypeScript
import * as z from 'zod';
|
|
|
|
import { blockedSubdomain } from '@cio/utils/constants';
|
|
|
|
export const ZGetOrganizations = z.object({
|
|
siteName: z.string().min(1).optional(),
|
|
customDomain: z.string().min(1).optional(),
|
|
/**
|
|
* Accepts a real boolean (server-side direct calls) or the URL-encoded `'true'`/`'false'`
|
|
* string Hono RPC produces when serializing query params.
|
|
*/
|
|
isCustomDomainVerified: z
|
|
.union([z.boolean(), z.literal('true').transform(() => true), z.literal('false').transform(() => false)])
|
|
.optional()
|
|
});
|
|
|
|
export type TGetOrganizations = z.infer<typeof ZGetOrganizations>;
|
|
|
|
export const ZGetCoursesBySiteName = z.object({
|
|
siteName: z.string().min(1),
|
|
tags: z.string().optional(),
|
|
types: z.string().optional(),
|
|
search: z.string().trim().optional(),
|
|
pricing: z.enum(['free', 'paid']).optional(),
|
|
page: z.coerce.number().int().min(1).optional(),
|
|
limit: z.coerce.number().int().min(1).max(100).optional()
|
|
});
|
|
|
|
export type TGetCoursesBySiteName = z.infer<typeof ZGetCoursesBySiteName>;
|
|
|
|
export const ZGetOrganizationCoursesQuery = z.object({
|
|
page: z.coerce.number().int().min(1).default(1),
|
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
search: z.string().trim().optional(),
|
|
tags: z.string().optional()
|
|
});
|
|
|
|
export type TGetOrganizationCoursesQuery = z.infer<typeof ZGetOrganizationCoursesQuery>;
|
|
|
|
export const ZGetOrgSetup = z.object({
|
|
siteName: z.string().min(1)
|
|
});
|
|
|
|
export type TGetOrgSetup = z.infer<typeof ZGetOrgSetup>;
|
|
|
|
export const ZCreateOrgPlan = z.object({
|
|
orgId: z.uuid(),
|
|
planName: z.enum(['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']),
|
|
subscriptionId: z.string().min(1),
|
|
triggeredBy: z.number().int().positive(),
|
|
payload: z.record(z.string(), z.unknown())
|
|
});
|
|
|
|
export type TCreateOrgPlan = z.infer<typeof ZCreateOrgPlan>;
|
|
|
|
export const ZUpdateOrgPlan = z.object({
|
|
subscriptionId: z.string().min(1),
|
|
payload: z.record(z.string(), z.unknown())
|
|
});
|
|
|
|
export type TUpdateOrgPlan = z.infer<typeof ZUpdateOrgPlan>;
|
|
|
|
export const ZCancelOrgPlan = z.object({
|
|
subscriptionId: z.string().min(1),
|
|
payload: z.record(z.string(), z.unknown())
|
|
});
|
|
|
|
export type TCancelOrgPlan = z.infer<typeof ZCancelOrgPlan>;
|
|
|
|
export const ZCreateOrganization = z.object({
|
|
name: z
|
|
.string()
|
|
.min(5)
|
|
.refine((val) => !/^[-]|[-]$/.test(val), {
|
|
message: 'Organization name cannot start or end with a hyphen'
|
|
}),
|
|
siteName: z
|
|
.string()
|
|
.min(5)
|
|
.refine((val) => !/^[-]|[-]$/.test(val), {
|
|
message: 'Site name cannot start or end with a hyphen'
|
|
})
|
|
.refine((val) => !blockedSubdomain.includes(val), {
|
|
message: 'Sitename already exists.'
|
|
})
|
|
});
|
|
|
|
export type TCreateOrganization = z.infer<typeof ZCreateOrganization>;
|
|
|
|
export const ZUpdateOrganization = z.object({
|
|
name: z
|
|
.string()
|
|
.min(5)
|
|
.refine((val) => !/^[-]|[-]$/.test(val), {
|
|
message: 'Organization name cannot start or end with a hyphen'
|
|
})
|
|
.optional(),
|
|
avatarUrl: z.url().optional(),
|
|
favicon: z.union([z.url(), z.null()]).optional(),
|
|
theme: z.string().optional(),
|
|
landingpage: z.record(z.string(), z.unknown()).optional(),
|
|
siteName: z.string().min(1).optional(),
|
|
customDomain: z.string().nullable().optional(),
|
|
isCustomDomainVerified: z.boolean().optional(),
|
|
customization: z.record(z.string(), z.unknown()).optional(),
|
|
disableSignup: z.boolean().optional(),
|
|
disableSignupMessage: z.string().optional(),
|
|
disableEmailPassword: z.boolean().optional(),
|
|
disableGoogleAuth: z.boolean().optional(),
|
|
/** Nested settings (stored in organization.settings JSONB). signup.inviteOnly = true means invite-only signup. */
|
|
settings: z
|
|
.object({
|
|
signup: z
|
|
.object({
|
|
inviteOnly: z.boolean().optional()
|
|
})
|
|
.optional(),
|
|
internalEnrollmentOnly: z.boolean().optional(),
|
|
emailNotifications: z
|
|
.object({
|
|
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(),
|
|
enrollmentWelcome: z.boolean().optional(),
|
|
courseCompletion: z.boolean().optional()
|
|
})
|
|
.optional()
|
|
})
|
|
.optional()
|
|
});
|
|
|
|
export type TUpdateOrganization = z.infer<typeof ZUpdateOrganization>;
|
|
|
|
export const ZInviteTeamMembers = z.object({
|
|
emails: z.array(z.string().email()).min(1).max(50),
|
|
roleId: z.number().int().positive()
|
|
});
|
|
|
|
export type TInviteTeamMembers = z.infer<typeof ZInviteTeamMembers>;
|
|
|
|
export const ZRemoveTeamMember = z.object({
|
|
memberId: z.coerce.number().int().positive()
|
|
});
|
|
|
|
export type TRemoveTeamMember = z.infer<typeof ZRemoveTeamMember>;
|
|
|
|
export const ZCreateLinkInvite = z.object({
|
|
roleId: z.number().int().positive()
|
|
});
|
|
|
|
export type TCreateLinkInvite = z.infer<typeof ZCreateLinkInvite>;
|
|
|
|
export const ZToggleLinkInvite = z.object({
|
|
isRevoked: z.boolean()
|
|
});
|
|
|
|
export type TToggleLinkInvite = z.infer<typeof ZToggleLinkInvite>;
|
|
|
|
export const ZGetUserAnalytics = z.object({
|
|
userId: z.uuid()
|
|
});
|
|
|
|
export type TGetUserAnalytics = z.infer<typeof ZGetUserAnalytics>;
|