UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

169 lines (153 loc) 5.63 kB
import { $inject, createPrimitive, KIND, Primitive, type Static, type StaticEncode, type TObject, } from "alepha"; import { currentTenantAtom } from "alepha/security"; import { NotificationJobs } from "../jobs/NotificationJobs.ts"; /** * Creates a notification primitive for managing email/SMS notification templates. * * Provides type-safe, reusable notification templates with multi-language support, * variable substitution, and categorization for different notification channels. * * @example * ```ts * class NotificationTemplates { * welcomeEmail = $notification({ * name: "welcome-email", * category: "onboarding", * schema: z.object({ username: z.text(), activationLink: z.text() }), * email: { * subject: "Welcome to our platform!", * body: (vars) => `Hello ${vars.username}, click: ${vars.activationLink}` * } * }); * * async sendWelcome(user: User) { * await this.welcomeEmail.push({ * variables: { username: user.name, activationLink: generateLink() }, * contact: user.email * }); * } * } * ``` */ export const $notification = <T extends TObject>( options: NotificationPrimitiveOptions<T>, ) => createPrimitive(NotificationPrimitive<T>, options); // --------------------------------------------------------------------------------------------------------------------- export interface NotificationPrimitiveOptions<T extends TObject> extends NotificationMessage<T> { name?: string; description?: string; category?: string; critical?: boolean; sensitive?: boolean; translations?: { // e.g., "en", "fr", even "en-US" [lang: string]: NotificationMessage<T>; }; schema: T; } // --------------------------------------------------------------------------------------------------------------------- export class NotificationPrimitive<T extends TObject> extends Primitive< NotificationPrimitiveOptions<T> > { protected readonly notificationJobs = $inject(NotificationJobs); public get name() { return this.options.name ?? `${this.config.propertyKey}`; } /** * Recipient language for `translations` resolution. Explicit `lang` wins; * otherwise the language is captured FROM THE CURRENT REQUEST at push time * (the i18n `lang` cookie, then `Accept-Language`) — the send job may run * out of request context, so this must be resolved here, not in the sender. */ protected resolveLang(explicit?: string): string | undefined { if (explicit) return explicit; const request = this.alepha.store.get("alepha.http.request") as | { headers?: Record<string, string | undefined> } | undefined; const cookie = request?.headers?.cookie; const fromCookie = cookie?.match(/(?:^|;\s*)lang=([a-zA-Z-]+)/)?.[1]; if (fromCookie) return fromCookie; const accept = request?.headers?.["accept-language"]; return accept?.split(",")[0]?.trim().split(";")[0] || undefined; } public async push(options: NotificationPushOptions<T>) { const lang = this.resolveLang(options.lang); // Tag the outbox row with the owning tenant so the notification admin list // stays org-scoped (the outbox is shared by every tenant in a pooled // worker). Explicit `organizationId` wins (cron sweeps run out of request // context and pass the subject's org); otherwise fall back to the tenant // resolved for the current request. const organizationId = options.organizationId ?? this.alepha.store.get(currentTenantAtom)?.id; const pushOpts = { ...(this.options.critical ? ({ priority: "critical" } as const) : {}), organizationId, }; if (this.options.email) { await this.notificationJobs.sendNotification.push( { type: "email", template: this.name, contact: options.contact, variables: options.variables as Record<string, unknown>, category: this.options.category, critical: this.options.critical, sensitive: this.options.sensitive, lang, }, pushOpts, ); } if (this.options.sms) { await this.notificationJobs.sendNotification.push( { type: "sms", template: this.name, contact: options.contact, variables: options.variables as Record<string, unknown>, category: this.options.category, critical: this.options.critical, sensitive: this.options.sensitive, lang, }, pushOpts, ); } } public configure(options: Partial<NotificationPrimitiveOptions<T>>) { Object.assign(this.options, options); } } $notification[KIND] = NotificationPrimitive; // --------------------------------------------------------------------------------------------------------------------- export interface NotificationPushOptions<T extends TObject> { variables: StaticEncode<T>; contact: string; /** Recipient language (e.g. "fr"); defaults to the current request's. */ lang?: string; /** * Owning tenant for this notification. Defaults to the tenant resolved for * the current request. Pass it explicitly when sending from a context with no * request tenant — e.g. a cron sweep that fans out across clubs (use the * subject entity's `organizationId`) — so the row stays correctly org-scoped. */ organizationId?: string; } export interface NotificationMessage<T extends TObject> { email?: { subject: string; body: string | ((variables: Static<T>) => string); }; sms?: { message: string | ((variables: Static<T>) => string); }; }