feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface";
|
||||
import { CompanyNotificationPolicyAdapterMongoose } from "./CompanyNotificationPolicy.Adapter.Mongoose";
|
||||
import { ClientNotificationPreferencesAdapterMongoose } from "./ClientNotificationPreferences.Adapter.Mongoose";
|
||||
import { ClientCompanyNotificationOverrideAdapterMongoose } from "./ClientCompanyNotificationOverride.Adapter.Mongoose";
|
||||
import { IClientNotificationPreferences } from "./ClientNotificationPreferences.Interface";
|
||||
import { IClientCompanyNotificationOverride } from "./ClientCompanyNotificationOverride.Interface";
|
||||
import { ICompanyNotificationPolicy } from "./CompanyNotificationPolicy.Interface";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||
import ClientsList from "../Clients/Clients";
|
||||
|
||||
const DEFAULT_TIMEZONE = "America/Argentina/Buenos_Aires";
|
||||
const SYSTEM_DEFAULT_CHANNELS: NotificationChannel[] = ["whatsapp", "email", "system"];
|
||||
|
||||
const PLAN_CHANNEL_MAP: Record<string, PlanFeatures> = {
|
||||
whatsapp: PlanFeatures.WAP_NOTIFICATIONS,
|
||||
email: PlanFeatures.MAIL_NOTIFICATIONS,
|
||||
};
|
||||
|
||||
export interface ResolvedPolicy {
|
||||
channels: NotificationChannel[];
|
||||
timezone: string;
|
||||
quietHours?: { from: string; to: string };
|
||||
reminderRules?: { offset: number; enabled: boolean }[];
|
||||
}
|
||||
|
||||
export class PolicyResolver {
|
||||
private companyPolicyAdapter: CompanyNotificationPolicyAdapterMongoose;
|
||||
private clientPreferencesAdapter: ClientNotificationPreferencesAdapterMongoose;
|
||||
private clientCompanyOverrideAdapter: ClientCompanyNotificationOverrideAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.companyPolicyAdapter = new CompanyNotificationPolicyAdapterMongoose();
|
||||
this.clientPreferencesAdapter = new ClientNotificationPreferencesAdapterMongoose();
|
||||
this.clientCompanyOverrideAdapter = new ClientCompanyNotificationOverrideAdapterMongoose();
|
||||
}
|
||||
|
||||
async resolve(companyId: string, clientId: string): Promise<ResolvedPolicy> {
|
||||
// Level 2: ClientCompanyNotificationOverride (per-client per-org)
|
||||
const override = await this.clientCompanyOverrideAdapter.findOne({
|
||||
clientId,
|
||||
companyId,
|
||||
});
|
||||
|
||||
const client = await ClientsList.clients.findOne({ _id: clientId });
|
||||
|
||||
// Level 3: ClientNotificationPreferences (user global)
|
||||
const clientPrefs = client?.userId
|
||||
? await this.clientPreferencesAdapter.findOne({ userId: String(client.userId) })
|
||||
: null;
|
||||
|
||||
// Level 4: CompanyNotificationPolicy (org defaults)
|
||||
const companyPolicy = await this.companyPolicyAdapter.findOne({ companyId });
|
||||
|
||||
// Determine channels from cascade
|
||||
let channels: NotificationChannel[];
|
||||
let timezone: string;
|
||||
let quietHours: ResolvedPolicy["quietHours"];
|
||||
let reminderRules: ResolvedPolicy["reminderRules"];
|
||||
|
||||
if (override) {
|
||||
channels = [...override.preferredChannels];
|
||||
} else if (clientPrefs) {
|
||||
channels = [...clientPrefs.preferredChannels];
|
||||
} else if (companyPolicy) {
|
||||
channels = [...companyPolicy.defaultChannels];
|
||||
} else {
|
||||
// Level 5: System defaults
|
||||
channels = [...SYSTEM_DEFAULT_CHANNELS];
|
||||
}
|
||||
|
||||
// Apply muted channels from the winning level only — do NOT merge across levels
|
||||
const mutedChannels = this.getMutedChannels(override, clientPrefs, companyPolicy);
|
||||
channels = channels.filter((ch) => !mutedChannels.includes(ch));
|
||||
|
||||
// Timezone and quiet hours from company policy
|
||||
if (companyPolicy) {
|
||||
timezone = companyPolicy.timezone;
|
||||
quietHours = companyPolicy.quietHours;
|
||||
reminderRules = companyPolicy.reminderRules;
|
||||
} else {
|
||||
timezone = DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
// Level 1: Plan limits — filter channels by plan features
|
||||
channels = await this.applyPlanLimits(companyId, channels);
|
||||
|
||||
return {
|
||||
channels,
|
||||
timezone,
|
||||
quietHours,
|
||||
reminderRules,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns muted channels from the winning cascade level only.
|
||||
* Per spec, muted channels are NOT merged across levels.
|
||||
* The level that determines preferredChannels also determines mutedChannels.
|
||||
*/
|
||||
private getMutedChannels(
|
||||
override: IClientCompanyNotificationOverride | null,
|
||||
clientPrefs: IClientNotificationPreferences | null,
|
||||
companyPolicy: ICompanyNotificationPolicy | null
|
||||
): NotificationChannel[] {
|
||||
if (override) {
|
||||
// Override wins — use only override's muted channels
|
||||
return override.mutedChannels || [];
|
||||
}
|
||||
if (clientPrefs) {
|
||||
// Client prefs wins — use only clientPrefs' muted channels
|
||||
return clientPrefs.mutedChannels || [];
|
||||
}
|
||||
if (companyPolicy) {
|
||||
// Company policy wins — use only companyPolicy' muted channels
|
||||
return companyPolicy.mutedChannels || [];
|
||||
}
|
||||
// System defaults — no muted channels
|
||||
return [];
|
||||
}
|
||||
|
||||
private async applyPlanLimits(
|
||||
companyId: string,
|
||||
channels: NotificationChannel[]
|
||||
): Promise<NotificationChannel[]> {
|
||||
// Resolve the company owner's userId for plan feature checks
|
||||
// Use lazy require to avoid circular dependency (PolicyResolver → CompaniesManager → Appointments → NotificationJobService → PolicyResolver)
|
||||
const CompaniesManager = require("../Companies/Companies").default;
|
||||
const company = await CompaniesManager.companies.findOne({ _id: companyId });
|
||||
if (!company) {
|
||||
// Company not found — return channels unchanged (fail-open)
|
||||
return channels;
|
||||
}
|
||||
|
||||
const ownerUserId = String(company.ownerId);
|
||||
const filtered: NotificationChannel[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
const planFeature = PLAN_CHANNEL_MAP[channel];
|
||||
if (!planFeature) {
|
||||
// Channel has no plan restriction (e.g., system)
|
||||
filtered.push(channel);
|
||||
continue;
|
||||
}
|
||||
|
||||
const canSend = await PlanSubscriptionsList.checkFeature({
|
||||
userId: ownerUserId,
|
||||
feature: planFeature,
|
||||
});
|
||||
|
||||
if (canSend) {
|
||||
filtered.push(channel);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user