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 <[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]> ---------
This commit is contained in:
co-authored by
Rotimi Best
parent
14db5363e7
commit
fa060f5f6d
@@ -0,0 +1 @@
|
||||
ALTER TABLE "profile" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1782660458258,
|
||||
"tag": "0002_majestic_masked_marvel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1783909129074,
|
||||
"tag": "0003_profile_settings",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,43 @@ export async function getProfilesByEmails(emails: string[]) {
|
||||
}
|
||||
|
||||
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
|
||||
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
|
||||
let setData = { ...data };
|
||||
|
||||
if (data.settings !== undefined) {
|
||||
const [existing] = await db
|
||||
.select({ settings: schema.profile.settings })
|
||||
.from(schema.profile)
|
||||
.where(eq(schema.profile.id, id))
|
||||
.limit(1);
|
||||
|
||||
const existingSettings = (existing?.settings as Record<string, unknown>) ?? {};
|
||||
const newSettings = data.settings as Record<string, unknown>;
|
||||
const mergedSettings: Record<string, unknown> = { ...existingSettings };
|
||||
|
||||
for (const key of Object.keys(newSettings ?? {})) {
|
||||
const existingVal = existingSettings[key];
|
||||
const newVal = newSettings[key];
|
||||
|
||||
if (newVal !== undefined) {
|
||||
if (
|
||||
existingVal &&
|
||||
typeof existingVal === 'object' &&
|
||||
!Array.isArray(existingVal) &&
|
||||
newVal &&
|
||||
typeof newVal === 'object' &&
|
||||
!Array.isArray(newVal)
|
||||
) {
|
||||
mergedSettings[key] = { ...(existingVal as Record<string, unknown>), ...(newVal as Record<string, unknown>) };
|
||||
} else {
|
||||
mergedSettings[key] = newVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setData = { ...setData, settings: mergedSettings as TProfile['settings'] };
|
||||
}
|
||||
|
||||
const [updatedProfile] = await db.update(schema.profile).set(setData).where(eq(schema.profile.id, id)).returning();
|
||||
|
||||
return updatedProfile;
|
||||
};
|
||||
|
||||
@@ -102,6 +102,7 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
email: string | null;
|
||||
cohortId: string;
|
||||
cohortName: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
organizationSiteName: string | null;
|
||||
organizationCustomDomain: string | null;
|
||||
@@ -119,6 +120,7 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
email: schema.cohortMember.email,
|
||||
cohortId: schema.cohort.id,
|
||||
cohortName: schema.cohort.name,
|
||||
organizationId: schema.organization.id,
|
||||
organizationName: schema.organization.name,
|
||||
organizationSiteName: schema.organization.siteName,
|
||||
organizationCustomDomain: schema.organization.customDomain,
|
||||
@@ -147,6 +149,7 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
email: row.email,
|
||||
cohortId: row.cohortId,
|
||||
cohortName: row.cohortName,
|
||||
organizationId: row.organizationId,
|
||||
organizationName: row.organizationName,
|
||||
organizationSiteName: row.organizationSiteName,
|
||||
organizationCustomDomain: row.organizationCustomDomain,
|
||||
|
||||
@@ -675,6 +675,7 @@ export type TCourseCertificationRow = {
|
||||
emailMessage?: string | null;
|
||||
} | null;
|
||||
title: string;
|
||||
orgId: string;
|
||||
orgSiteName: string | null;
|
||||
orgCustomDomain: string | null;
|
||||
orgIsCustomDomainVerified: boolean | null;
|
||||
@@ -691,6 +692,7 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
|
||||
compliance: schema.course.compliance,
|
||||
certificate: schema.course.certificate,
|
||||
title: schema.course.title,
|
||||
orgId: schema.organization.id,
|
||||
orgSiteName: schema.organization.siteName,
|
||||
orgCustomDomain: schema.organization.customDomain,
|
||||
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
|
||||
@@ -1174,6 +1176,7 @@ export const getExploreCourses = async ({
|
||||
*/
|
||||
export async function getCourseWithOrgData(courseId: string): Promise<{
|
||||
courseTitle: string | null;
|
||||
orgId: string;
|
||||
orgName: string | null;
|
||||
orgSiteName: string | null;
|
||||
orgCustomDomain: string | null;
|
||||
@@ -1187,6 +1190,7 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
|
||||
const result = await db
|
||||
.select({
|
||||
courseTitle: schema.course.title,
|
||||
orgId: schema.organization.id,
|
||||
orgName: schema.organization.name,
|
||||
orgSiteName: schema.organization.siteName,
|
||||
orgCustomDomain: schema.organization.customDomain,
|
||||
|
||||
@@ -15,6 +15,7 @@ export type UpcomingSessionReminderRow = {
|
||||
callUrl: string;
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
organizationSiteName: string | null;
|
||||
organizationCustomDomain: string | null;
|
||||
@@ -44,6 +45,7 @@ export async function listUpcomingSessionsForReminderScan(): Promise<UpcomingSes
|
||||
callUrl: schema.lesson.callUrl,
|
||||
profileId: schema.groupmember.profileId,
|
||||
email: schema.profile.email,
|
||||
organizationId: schema.organization.id,
|
||||
organizationName: schema.organization.name,
|
||||
organizationSiteName: schema.organization.siteName,
|
||||
organizationCustomDomain: schema.organization.customDomain,
|
||||
@@ -82,6 +84,7 @@ export async function listUpcomingSessionsForReminderScan(): Promise<UpcomingSes
|
||||
callUrl: row.callUrl!,
|
||||
profileId: row.profileId!,
|
||||
email: row.email,
|
||||
organizationId: row.organizationId,
|
||||
organizationName: row.organizationName,
|
||||
organizationSiteName: row.organizationSiteName,
|
||||
organizationCustomDomain: row.organizationCustomDomain,
|
||||
|
||||
@@ -399,6 +399,7 @@ export async function getNewsfeedForEmail(
|
||||
email: string | null;
|
||||
} | null;
|
||||
organization: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
siteName: string | null;
|
||||
avatarUrl: string | null;
|
||||
@@ -462,6 +463,7 @@ export async function getNewsfeedForEmail(
|
||||
}
|
||||
: null,
|
||||
organization: {
|
||||
id: row.organization.id,
|
||||
name: row.organization.name,
|
||||
siteName: row.organization.siteName,
|
||||
avatarUrl: row.organization.avatarUrl,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
resolveEmailDelivery,
|
||||
type EmailNotificationSettings,
|
||||
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 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;
|
||||
recipientEmail?: string;
|
||||
recipientProfileId?: string;
|
||||
}): Promise<boolean> {
|
||||
return new EmailPreferenceLookupCache().shouldSend(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses org/profile settings lookups across many recipients in batch processors.
|
||||
*/
|
||||
export class EmailPreferenceLookupCache {
|
||||
private orgSettingsById = new Map<string, EmailNotificationSettings | undefined>();
|
||||
private profileSettingsById = new Map<string, PersonalEmailNotificationSettings | undefined>();
|
||||
private profileIdByEmail = new Map<string, string | undefined>();
|
||||
|
||||
async shouldSend(input: {
|
||||
emailId: EmailId;
|
||||
organizationId?: string;
|
||||
recipientEmail?: string;
|
||||
recipientProfileId?: string;
|
||||
}): Promise<boolean> {
|
||||
const orgSettings = input.organizationId ? await this.getOrganizationSettings(input.organizationId) : undefined;
|
||||
|
||||
let profileId = input.recipientProfileId;
|
||||
|
||||
if (!profileId && input.recipientEmail) {
|
||||
profileId = await this.getProfileIdByEmail(input.recipientEmail);
|
||||
}
|
||||
|
||||
const personalSettings = profileId ? await this.getProfileSettings(profileId) : undefined;
|
||||
|
||||
return resolveEmailDelivery(input.emailId, orgSettings, personalSettings);
|
||||
}
|
||||
|
||||
private async getOrganizationSettings(orgId: string): Promise<EmailNotificationSettings | undefined> {
|
||||
if (!this.orgSettingsById.has(orgId)) {
|
||||
const settings = await getOrganizationEmailNotificationSettings(orgId);
|
||||
this.orgSettingsById.set(orgId, settings);
|
||||
}
|
||||
|
||||
return this.orgSettingsById.get(orgId);
|
||||
}
|
||||
|
||||
private async getProfileIdByEmail(email: string): Promise<string | undefined> {
|
||||
if (!this.profileIdByEmail.has(email)) {
|
||||
const profile = await getProfileByEmail(email);
|
||||
this.profileIdByEmail.set(email, profile?.id);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return this.profileSettingsById.get(profileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './email-preferences';
|
||||
@@ -409,7 +409,19 @@ export const profile = pgTable(
|
||||
isEmailVerified: boolean('is_email_verified').default(false),
|
||||
verifiedAt: timestamp('verified_at', { withTimezone: true, mode: 'string' }),
|
||||
locale: locale().default('en'),
|
||||
isRestricted: boolean('is_restricted').default(false).notNull()
|
||||
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;
|
||||
};
|
||||
}>()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
@@ -2013,6 +2025,17 @@ export const organization = pgTable(
|
||||
half?: boolean;
|
||||
reached?: boolean;
|
||||
};
|
||||
emailNotifications?: {
|
||||
newStudent?: boolean;
|
||||
newSubmission?: boolean;
|
||||
gradingResult?: boolean;
|
||||
newsfeed?: boolean;
|
||||
quizAssigned?: boolean;
|
||||
cohortReminder?: boolean;
|
||||
session?: boolean;
|
||||
enrollmentWelcome?: boolean;
|
||||
courseCompletion?: boolean;
|
||||
};
|
||||
}>(),
|
||||
landingpage: jsonb().default({}).$type<{
|
||||
header?: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ZNotifyCourseExercisePayload = z.object({
|
||||
exerciseTitle: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
orgName: z.string().min(1),
|
||||
organizationId: z.string().min(1),
|
||||
fromName: z.string().min(1),
|
||||
quizUrl: z.string().min(1),
|
||||
branding: ZNotifyBranding
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"./org-site/og-public-url": {
|
||||
"types": "./dist/org-site/og-public-url.d.ts",
|
||||
"default": "./dist/org-site/og-public-url.js"
|
||||
},
|
||||
"./notifications": {
|
||||
"types": "./dist/notifications/index.d.ts",
|
||||
"default": "./dist/notifications/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export const EMAIL_NOTIFICATION_TOGGLE_KEYS = [
|
||||
'newStudent',
|
||||
'newSubmission',
|
||||
'gradingResult',
|
||||
'newsfeed',
|
||||
'quizAssigned',
|
||||
'cohortReminder',
|
||||
'session',
|
||||
'enrollmentWelcome',
|
||||
'courseCompletion'
|
||||
] as const;
|
||||
|
||||
export type EmailNotificationToggleKey = (typeof EMAIL_NOTIFICATION_TOGGLE_KEYS)[number];
|
||||
|
||||
export type PersonalEmailNotificationToggleKey = Exclude<EmailNotificationToggleKey, 'enrollmentWelcome'>;
|
||||
|
||||
export const PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS = EMAIL_NOTIFICATION_TOGGLE_KEYS.filter(
|
||||
(key): key is PersonalEmailNotificationToggleKey => key !== 'enrollmentWelcome'
|
||||
);
|
||||
|
||||
/** Personal toggles that only apply to tutors/admins — hidden from pure students in the UI. */
|
||||
export const TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS = ['newStudent', 'newSubmission'] as const;
|
||||
|
||||
export type TutorOnlyPersonalEmailNotificationToggleKey =
|
||||
(typeof TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS)[number];
|
||||
|
||||
export type EmailNotificationSectionId = 'course' | 'cohort' | 'enrollment';
|
||||
|
||||
export type EmailNotificationSection<TKey extends string = EmailNotificationToggleKey> = {
|
||||
id: EmailNotificationSectionId;
|
||||
keys: readonly TKey[];
|
||||
};
|
||||
|
||||
export const PERSONAL_EMAIL_NOTIFICATION_SECTIONS: EmailNotificationSection<PersonalEmailNotificationToggleKey>[] = [
|
||||
{
|
||||
id: 'course',
|
||||
keys: ['newStudent', 'newSubmission', 'gradingResult', 'newsfeed', 'quizAssigned', 'session', 'courseCompletion']
|
||||
},
|
||||
{
|
||||
id: 'cohort',
|
||||
keys: ['cohortReminder']
|
||||
}
|
||||
];
|
||||
|
||||
export const ORG_EMAIL_NOTIFICATION_SECTIONS: EmailNotificationSection[] = [
|
||||
{
|
||||
id: 'course',
|
||||
keys: ['newStudent', 'newSubmission', 'gradingResult', 'newsfeed', 'quizAssigned', 'session', 'courseCompletion']
|
||||
},
|
||||
{
|
||||
id: 'cohort',
|
||||
keys: ['cohortReminder']
|
||||
},
|
||||
{
|
||||
id: 'enrollment',
|
||||
keys: ['enrollmentWelcome']
|
||||
}
|
||||
];
|
||||
|
||||
export type EmailNotificationSettings = Partial<Record<EmailNotificationToggleKey, boolean>>;
|
||||
|
||||
export type PersonalEmailNotificationSettings = Partial<Record<PersonalEmailNotificationToggleKey, boolean>>;
|
||||
|
||||
export const EMAIL_TOGGLE_MAP = {
|
||||
teacherStudentJoined: 'newStudent',
|
||||
submissionReceived: 'newSubmission',
|
||||
submissionGraded: 'gradingResult',
|
||||
newsfeedPost: 'newsfeed',
|
||||
newsfeedComment: 'newsfeed',
|
||||
quizAssigned: 'quizAssigned',
|
||||
cohortGoalReminder: 'cohortReminder',
|
||||
sessionReminder: 'session',
|
||||
sessionUpdated: 'session',
|
||||
studentCourseWelcome: 'enrollmentWelcome',
|
||||
studentCohortWelcome: 'enrollmentWelcome',
|
||||
teacherCourseWelcome: 'enrollmentWelcome',
|
||||
studentCourseCompletion: 'courseCompletion'
|
||||
} as const satisfies Record<string, EmailNotificationToggleKey>;
|
||||
|
||||
export type ToggleableEmailId = keyof typeof EMAIL_TOGGLE_MAP;
|
||||
|
||||
export function getToggleKeyForEmail(emailId: string): EmailNotificationToggleKey | undefined {
|
||||
return EMAIL_TOGGLE_MAP[emailId as ToggleableEmailId];
|
||||
}
|
||||
|
||||
export function isMandatoryEmail(emailId: string): boolean {
|
||||
return getToggleKeyForEmail(emailId) === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure preference resolution. Absent keys resolve to allowed (opt-out default).
|
||||
*/
|
||||
export function resolveEmailDelivery(
|
||||
emailId: string,
|
||||
orgSettings?: EmailNotificationSettings | null,
|
||||
personalSettings?: PersonalEmailNotificationSettings | null
|
||||
): boolean {
|
||||
const toggleKey = getToggleKeyForEmail(emailId);
|
||||
|
||||
if (!toggleKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (orgSettings?.[toggleKey] === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toggleKey === 'enrollmentWelcome') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (personalSettings?.[toggleKey] === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './email-toggles';
|
||||
@@ -1,10 +1,22 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
const ZEmailNotificationToggleFields = {
|
||||
newStudent: z.boolean().optional(),
|
||||
newSubmission: z.boolean().optional(),
|
||||
gradingResult: z.boolean().optional(),
|
||||
newsfeed: z.boolean().optional(),
|
||||
quizAssigned: z.boolean().optional(),
|
||||
cohortReminder: z.boolean().optional(),
|
||||
session: z.boolean().optional(),
|
||||
courseCompletion: z.boolean().optional()
|
||||
};
|
||||
|
||||
export const ZUpdateProfile = z.object({
|
||||
fullname: z.string().min(3).optional(),
|
||||
username: z.string().min(3).optional(),
|
||||
locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']).optional(),
|
||||
avatarUrl: z.url().optional()
|
||||
avatarUrl: z.url().optional(),
|
||||
emailNotifications: z.object(ZEmailNotificationToggleFields).optional()
|
||||
});
|
||||
|
||||
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
|
||||
|
||||
@@ -115,7 +115,20 @@ export const ZUpdateOrganization = z.object({
|
||||
inviteOnly: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
internalEnrollmentOnly: z.boolean().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()
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user