import { BNestPrismaService } from "@bish-nest/core/services";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
export interface EmailPreferenceDto {
emailType: string;
enabled: boolean;
preferredTime?: string;
preferredDay?: "weekday" | "weekend" | "any";
timezone?: string;
}
export interface EmailPreferences {
frequency: "immediate" | "daily" | "weekly" | "monthly" | "never";
categories: {
inactivityReminders: boolean;
courseProgress: boolean;
deadlines: boolean;
assessments: boolean;
achievements: boolean;
weeklyDigest: boolean;
};
quietHours: {
start: string; // HH:mm format
end: string; // HH:mm format
};
timezone: string;
preferredSendTime: string; // HH:mm format
}
export type EmailPreferencesPatch = Partial<Omit<EmailPreferences, "categories" | "quietHours">> & {
categories?: Partial<EmailPreferences["categories"]>;
quietHours?: Partial<EmailPreferences["quietHours"]>;
};
@Injectable()
export class EmailPreferencesService {
private readonly logger = new Logger(EmailPreferencesService.name);
constructor(private prisma: BNestPrismaService) {}
/**
* Get email preferences for a participant
*/
async getParticipantPreferences(participantId: number): Promise<EmailPreferences> {
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
throw new NotFoundException(`Participant not found: ${participantId}`);
}
// Query email_preferences table
let prefs = await (this.prisma.client as any).emailPreference.findUnique({
where: { participant_id: participantId },
});
// If not found, create defaults
if (!prefs) {
prefs = await this.createDefaultPreferences(participantId);
}
return {
frequency: (prefs.frequency as "immediate" | "daily" | "weekly" | "monthly" | "never") || "immediate",
categories: {
inactivityReminders: prefs.inactivity_reminders,
courseProgress: prefs.course_progress,
deadlines: prefs.deadlines,
assessments: prefs.assessments,
achievements: prefs.achievements,
weeklyDigest: prefs.weekly_digest,
},
quietHours: {
start: prefs.quiet_hours_start || "22:00",
end: prefs.quiet_hours_end || "08:00",
},
timezone: prefs.timezone || "UTC",
preferredSendTime: prefs.preferred_send_time || "10:00",
};
}
/**
* Create default preferences for a participant
*/
private async createDefaultPreferences(participantId: number) {
return await (this.prisma.client as any).emailPreference.create({
data: {
participant_id: participantId,
frequency: "immediate",
inactivity_reminders: true,
course_progress: true,
deadlines: true,
assessments: true,
achievements: true,
weekly_digest: true,
quiet_hours_start: "22:00",
quiet_hours_end: "08:00",
timezone: "UTC",
preferred_send_time: "10:00",
all_reminders_enabled: true,
resubscribe_count: 0,
},
});
}
/**
* Update email preferences for a participant
*/
async updateParticipantPreferences(
participantId: number,
preferences: EmailPreferencesPatch,
): Promise<EmailPreferences> {
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
throw new NotFoundException(`Participant not found: ${participantId}`);
}
// Get or create preferences
let prefs = await (this.prisma.client as any).emailPreference.findUnique({
where: { participant_id: participantId },
});
if (!prefs) {
prefs = await this.createDefaultPreferences(participantId);
}
// Update preferences
const updated = await (this.prisma.client as any).emailPreference.update({
where: { id: prefs.id },
data: {
frequency: preferences.frequency || prefs.frequency,
inactivity_reminders: preferences.categories?.inactivityReminders ?? prefs.inactivity_reminders,
course_progress: preferences.categories?.courseProgress ?? prefs.course_progress,
deadlines: preferences.categories?.deadlines ?? prefs.deadlines,
assessments: preferences.categories?.assessments ?? prefs.assessments,
achievements: preferences.categories?.achievements ?? prefs.achievements,
weekly_digest: preferences.categories?.weeklyDigest ?? prefs.weekly_digest,
quiet_hours_start: preferences.quietHours?.start || prefs.quiet_hours_start,
quiet_hours_end: preferences.quietHours?.end || prefs.quiet_hours_end,
timezone: preferences.timezone || prefs.timezone,
preferred_send_time: preferences.preferredSendTime || prefs.preferred_send_time,
},
});
this.logger.log(`Updated preferences for participant ${participantId}`);
return this.getParticipantPreferences(participantId);
}
/**
* Check if email type is enabled for participant
*/
async isEmailTypeEnabled(participantId: number, emailType: string): Promise<boolean> {
const preferences = await this.getParticipantPreferences(participantId);
// Map email types to preference categories
const categoryMap: Record<string, keyof EmailPreferences["categories"]> = {
inactivity: "inactivityReminders",
course_progress: "courseProgress",
deadline: "deadlines",
assessment: "assessments",
achievement: "achievements",
weekly_digest: "weeklyDigest",
winback: "inactivityReminders", // Win-back emails are treated as inactivity reminders
};
const category = categoryMap[emailType.toLowerCase()];
if (category) {
return preferences.categories[category];
}
// Default to enabled if not found
return true;
}
/**
* Check if email should be sent based on preferences
*/
async shouldSendEmail(participantId: number, emailType: string, scheduledTime?: Date): Promise<boolean> {
// First check if participant is unsubscribed
if (await this.isUnsubscribed(participantId)) {
return false;
}
const prefs = await this.getParticipantPreferences(participantId);
// Check frequency preference
if (prefs.frequency === "never") {
return false;
}
// Check category preference
const categoryEnabled = await this.isEmailTypeEnabled(participantId, emailType);
if (!categoryEnabled) {
return false;
}
// Check quiet hours
if (scheduledTime) {
const hour = scheduledTime.getHours();
const minute = scheduledTime.getMinutes();
const [startHour, startMinute] = prefs.quietHours.start.split(":").map(Number);
const [endHour, endMinute] = prefs.quietHours.end.split(":").map(Number);
const currentTime = hour * 60 + minute;
const startTime = startHour * 60 + startMinute;
const endTime = endHour * 60 + endMinute;
// Handle quiet hours that span midnight
if (startTime > endTime) {
if (currentTime >= startTime || currentTime < endTime) {
return false; // In quiet hours
}
} else {
if (currentTime >= startTime && currentTime < endTime) {
return false; // In quiet hours
}
}
}
return true;
}
/**
* Unsubscribe a participant from all email reminders
*/
async unsubscribeParticipant(participantId: number, reason?: string): Promise<void> {
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
throw new NotFoundException(`Participant not found: ${participantId}`);
}
// Get or create preferences
let prefs = await (this.prisma.client as any).emailPreference.findUnique({
where: { participant_id: participantId },
});
if (!prefs) {
prefs = await this.createDefaultPreferences(participantId);
}
// Update to unsubscribe
await (this.prisma.client as any).emailPreference.update({
where: { id: prefs.id },
data: {
all_reminders_enabled: false,
unsubscribed_at: new Date(),
unsubscribed_reason: reason || null,
},
});
this.logger.log(`Unsubscribed participant ${participantId}${reason ? ` with reason: ${reason}` : ""}`);
}
/**
* Re-subscribe a participant to email reminders
*/
async resubscribeParticipant(participantId: number, source: string): Promise<void> {
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
throw new NotFoundException(`Participant not found: ${participantId}`);
}
// Get or create preferences
let prefs = await (this.prisma.client as any).emailPreference.findUnique({
where: { participant_id: participantId },
});
if (!prefs) {
prefs = await this.createDefaultPreferences(participantId);
}
// Get current resubscribe count
const currentResubscribeCount = prefs.resubscribe_count || 0;
// Update to resubscribe
await (this.prisma.client as any).emailPreference.update({
where: { id: prefs.id },
data: {
all_reminders_enabled: true,
resubscribed_at: new Date(),
resubscribe_count: currentResubscribeCount + 1,
unsubscribed_reason: null, // Clear unsubscribe reason
unsubscribed_at: null, // Clear unsubscribed timestamp
},
});
this.logger.log(`Resubscribed participant ${participantId} from source: ${source}`);
}
/**
* Check if a participant is unsubscribed
*/
async isUnsubscribed(participantId: number): Promise<boolean> {
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
return false; // If participant doesn't exist, default to not unsubscribed
}
// Query email_preferences table
const prefs = await (this.prisma.client as any).emailPreference.findUnique({
where: { participant_id: participantId },
});
// If no preferences exist, participant is not unsubscribed
if (!prefs) {
return false;
}
// Check if all_reminders_enabled is false or if unsubscribed_at is set
return prefs.all_reminders_enabled === false || prefs.unsubscribed_at !== null;
}
/**
* Get preference options for smart unsubscribe (before full unsubscribe)
*/
async getPreferenceOptions(participantId: number): Promise<{
currentFrequency: string;
currentPreferences: EmailPreferences;
options: Array<{
type: string;
label: string;
description: string;
action: string;
}>;
}> {
const preferences = await this.getParticipantPreferences(participantId);
const isUnsubscribed = await this.isUnsubscribed(participantId);
const options = [
{
type: "reduce_frequency",
label: "Send me weekly digest instead",
description: "Receive one weekly email with all updates instead of multiple emails",
action: "update_frequency",
value: "weekly",
},
{
type: "disable_specific",
label: "Disable inactivity reminders only",
description: "Keep important emails like deadlines and assessments",
action: "disable_category",
value: "inactivityReminders",
},
{
type: "disable_progress",
label: "Disable course progress updates",
description: "Keep deadlines and assessments, but skip progress notifications",
action: "disable_category",
value: "courseProgress",
},
{
type: "full_unsubscribe",
label: "Unsubscribe from all email reminders",
description: "You'll still receive essential account emails (password resets, security alerts)",
action: "full_unsubscribe",
},
];
return {
currentFrequency: preferences.frequency,
currentPreferences: preferences,
options,
};
}
/**
* Update preferences without full unsubscribe (smart unsubscribe)
*/
async updatePreferencesWithoutUnsubscribe(
participantId: number,
action: string,
value?: string,
): Promise<EmailPreferences> {
const preferences = await this.getParticipantPreferences(participantId);
if (action === "update_frequency" && value) {
// Change frequency to weekly digest
return await this.updateParticipantPreferences(participantId, {
frequency: value as "weekly",
categories: preferences.categories, // Keep all categories enabled
});
} else if (action === "disable_category" && value) {
// Disable specific category
const categoryMap: Record<string, keyof EmailPreferences["categories"]> = {
inactivityReminders: "inactivityReminders",
courseProgress: "courseProgress",
deadlines: "deadlines",
assessments: "assessments",
achievements: "achievements",
weeklyDigest: "weeklyDigest",
};
const category = categoryMap[value];
if (category) {
const updatedCategories = {
...preferences.categories,
[category]: false,
};
return await this.updateParticipantPreferences(participantId, {
categories: updatedCategories,
});
}
}
// If no valid action, return current preferences
return preferences;
}
/**
* Unsubscribe a super admin from email reminders
*/
async unsubscribeSuperAdmin(userId: number, reason?: string): Promise<void> {
this.logger.log(
`Unsubscribing super admin ${userId} from email reminders${reason ? ` - Reason: ${reason}` : ""}`,
);
// Store unsubscribe status in user metadata or system settings
// For now, we'll use a simple approach: store in SystemSetting or User metadata
// Since we don't have a UserEmailPreference table, we'll use a simple flag approach
try {
// Check if SystemSetting exists for user email preferences
const existingSetting = await this.prisma.client.systemSetting.findFirst({
where: {
key: `user_email_preference_${userId}`,
},
});
const unsubscribeData = {
unsubscribed: true,
unsubscribed_at: new Date().toISOString(),
unsubscribed_reason: reason || null,
user_id: userId,
};
if (existingSetting) {
await this.prisma.client.systemSetting.update({
where: { id: existingSetting.id },
data: {
value: unsubscribeData as any,
updated_at: new Date(),
},
});
} else {
await this.prisma.client.systemSetting.create({
data: {
key: `user_email_preference_${userId}`,
value: unsubscribeData as any,
description: `Email preference for super admin user ${userId}`,
} as any,
});
}
this.logger.log(`Super admin ${userId} unsubscribed from email reminders`);
} catch (error) {
this.logger.error(`Failed to unsubscribe super admin ${userId}:`, error);
throw error;
}
}
/**
* Resubscribe a super admin to email reminders
*/
async resubscribeSuperAdmin(userId: number, source: string): Promise<void> {
this.logger.log(`Resubscribing super admin ${userId} to email reminders (source: ${source})`);
try {
const existingSetting = await this.prisma.client.systemSetting.findFirst({
where: {
key: `user_email_preference_${userId}`,
},
});
if (existingSetting) {
const currentValue = existingSetting.value as any;
const resubscribeData = {
...currentValue,
unsubscribed: false,
unsubscribed_at: null,
unsubscribed_reason: null,
resubscribed_at: new Date().toISOString(),
resubscribe_source: source,
resubscribe_count: ((currentValue?.resubscribe_count || 0) as number) + 1,
};
await this.prisma.client.systemSetting.update({
where: { id: existingSetting.id },
data: {
value: resubscribeData as any,
updated_at: new Date(),
},
});
} else {
// If no setting exists, create one with resubscribed status
await this.prisma.client.systemSetting.create({
data: {
key: `user_email_preference_${userId}`,
value: {
unsubscribed: false,
resubscribed_at: new Date().toISOString(),
resubscribe_source: source,
resubscribe_count: 1,
} as any,
description: `Email preference for super admin user ${userId}`,
} as any,
});
}
this.logger.log(`Super admin ${userId} resubscribed to email reminders`);
} catch (error) {
this.logger.error(`Failed to resubscribe super admin ${userId}:`, error);
throw error;
}
}
/**
* Check if a super admin is unsubscribed
*/
async isSuperAdminUnsubscribed(userId: number): Promise<boolean> {
try {
const setting = await this.prisma.client.systemSetting.findFirst({
where: {
key: `user_email_preference_${userId}`,
},
});
if (!setting) {
return false; // Default to not unsubscribed
}
const value = setting.value as any;
return value?.unsubscribed === true;
} catch (error) {
this.logger.warn(`Failed to check super admin unsubscribe status for user ${userId}:`, error);
return false; // Default to not unsubscribed on error
}
}
}