alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
169 lines (153 loc) • 5.63 kB
text/typescript
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);
};
}