alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
482 lines (481 loc) • 17.2 kB
JavaScript
import { $inject, $module, Alepha, AlephaError, KIND, Primitive, createPrimitive, z } from "alepha";
import { $job, AlephaApiJobs, jobExecutionEntity } from "alepha/api/jobs";
import { $parameter, AlephaApiParameters } from "alepha/api/parameters";
import { $repository, pageQuerySchema } from "alepha/orm";
import { $secure, currentTenantAtom } from "alepha/security";
import { $action, NotFoundError, okSchema } from "alepha/server";
import { DateTimeProvider } from "alepha/datetime";
import { $logger, logEntrySchema } from "alepha/logger";
import { EmailProvider } from "alepha/email";
import { SmsProvider } from "alepha/sms";
//#region ../../src/api/notifications/schemas/notificationPayloadSchema.ts
const notificationPayloadSchema = z.object({
type: z.enum(["email", "sms"]),
template: z.text(),
contact: z.text(),
variables: z.record(z.text(), z.any()).optional(),
category: z.text().optional(),
critical: z.boolean().optional(),
sensitive: z.boolean().optional(),
/** Recipient language (e.g. "fr" or "fr-FR") used to pick `translations`. */
lang: z.text().optional()
});
//#endregion
//#region ../../src/api/notifications/primitives/$notification.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
* });
* }
* }
* ```
*/
const $notification = (options) => createPrimitive(NotificationPrimitive, options);
var NotificationPrimitive = class extends Primitive {
notificationJobs = $inject(NotificationJobs);
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.
*/
resolveLang(explicit) {
if (explicit) return explicit;
const request = this.alepha.store.get("alepha.http.request");
const fromCookie = (request?.headers?.cookie)?.match(/(?:^|;\s*)lang=([a-zA-Z-]+)/)?.[1];
if (fromCookie) return fromCookie;
return (request?.headers?.["accept-language"])?.split(",")[0]?.trim().split(";")[0] || void 0;
}
async push(options) {
const lang = this.resolveLang(options.lang);
const organizationId = options.organizationId ?? this.alepha.store.get(currentTenantAtom)?.id;
const pushOpts = {
...this.options.critical ? { priority: "critical" } : {},
organizationId
};
if (this.options.email) await this.notificationJobs.sendNotification.push({
type: "email",
template: this.name,
contact: options.contact,
variables: options.variables,
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,
category: this.options.category,
critical: this.options.critical,
sensitive: this.options.sensitive,
lang
}, pushOpts);
}
configure(options) {
Object.assign(this.options, options);
}
};
$notification[KIND] = NotificationPrimitive;
//#endregion
//#region ../../src/api/notifications/services/NotificationSenderService.ts
var NotificationSenderService = class {
alepha = $inject(Alepha);
log = $logger();
emailProvider = $inject(EmailProvider);
smsProvider = $inject(SmsProvider);
async send(payload) {
this.log.debug("Processing notification", {
type: payload.type,
template: payload.template,
contact: payload.contact
});
if (payload.type === "email") {
const rendered = this.renderEmail(payload);
await this.emailProvider.send(rendered);
this.log.info("Email notification sent", {
template: payload.template,
contact: payload.contact
});
return {
type: "email",
to: rendered.to,
subject: rendered.subject,
body: rendered.body
};
}
if (payload.type === "sms") {
const rendered = this.renderSms(payload);
await this.smsProvider.send(rendered);
this.log.info("SMS notification sent", {
template: payload.template,
contact: payload.contact
});
return {
type: "sms",
to: rendered.to,
message: rendered.message
};
}
}
renderSms(payload) {
const { variables, contact, template } = this.load(payload);
const sms = this.translation(template.options, payload.lang)?.sms ?? template.options.sms;
if (!sms) throw new AlephaError(`Notification template ${payload.template} has no sms defined`);
return {
to: contact,
message: typeof sms.message === "function" ? sms.message(variables) : sms.message
};
}
renderEmail(payload) {
const { variables, contact, template } = this.load(payload);
const email = this.translation(template.options, payload.lang)?.email ?? template.options.email;
if (!email) throw new AlephaError(`Notification template ${payload.template} has no email defined`);
return {
to: contact,
subject: email.subject,
body: typeof email.body === "function" ? email.body(variables) : email.body
};
}
/**
* Pick the template's `translations` entry for the payload language:
* exact match ("fr-FR") first, then the base language ("fr"). Returns
* undefined when there is no match — callers fall back to the default
* (template-level) message.
*/
translation(options, lang) {
if (!lang || !options.translations) return void 0;
const exact = options.translations[lang];
if (exact) return exact;
const base = lang.split("-")[0]?.toLowerCase();
return base ? options.translations[base] : void 0;
}
load(payload) {
const variables = payload.variables || {};
const contact = payload.contact;
const template = this.alepha.primitives($notification).find((it) => it.name === payload.template);
if (!template) throw new AlephaError(`No notification template found for ${payload.template}`);
return {
template,
variables,
contact
};
}
};
//#endregion
//#region ../../src/api/notifications/jobs/NotificationJobs.ts
/**
* Notification jobs + runtime-editable retention.
*
* - `settings` — a `$parameter` exposing `retentionDays` that admins can
* update at runtime. Changes propagate across instances via the parameter
* pub/sub; the next purge run picks up the new value with no restart.
* - `sendNotification` — queue-mode, audit-oriented. Every execution is kept
* (`record: "all"`, `keep: { ok: 0, error: 0 }` disables the ring-buffer
* trim) so the audit trail survives even under heavy volume.
* - `purgeOldNotifications` — cron sweep that deletes notification execution
* rows whose `completedAt` is older than the current `retentionDays`.
*
* Cron expression note: the purge cron is declared statically (`0 3 * * *`)
* because some runtimes (Cloudflare Workers) freeze cron triggers at deploy
* time. The *retention window* is fully runtime-editable — that's the knob
* that actually matters for operators.
*/
var NotificationJobs = class {
log = $logger();
dt = $inject(DateTimeProvider);
notificationSenderService = $inject(NotificationSenderService);
executions = $repository(jobExecutionEntity);
/** Runtime-editable config. Admins can change retentionDays without deploy. */
settings = $parameter({
name: "alepha.api.notifications",
description: "Notification delivery & retention settings.",
schema: z.object({ retentionDays: z.integer().min(1).describe("Days to keep notification execution rows before the purge sweep removes them.") }),
default: { retentionDays: 7 }
});
sendNotification = $job({
name: "api:notifications:sendNotification",
description: "Sends a notification (email/SMS) and keeps every execution for audit.",
schema: notificationPayloadSchema,
retry: { retries: 3 },
timeout: [30, "seconds"],
record: "all",
keep: {
ok: 0,
error: 0
},
handler: async ({ payload }) => {
await this.notificationSenderService.send(payload);
}
});
purgeOldNotifications = $job({
name: "api:notifications:purgeOldNotifications",
description: "Hourly sweep that deletes notification execution rows older than the configured retention window.",
cron: "0 * * * *",
handler: async ({ now }) => {
const { retentionDays } = this.settings.cachedCurrentContent;
const cutoff = now.subtract(retentionDays, "day").toISOString();
const jobName = this.sendNotification.name;
const expired = await this.executions.findMany({
where: {
jobName: { eq: jobName },
status: { inArray: [
"ok",
"error",
"cancelled"
] },
completedAt: { lt: cutoff }
},
columns: ["id"],
limit: 5e3
});
if (expired.length === 0) {
this.log.debug("Notification purge: nothing to delete", {
cutoff,
retentionDays
});
return;
}
await this.executions.deleteMany({ id: { inArray: expired.map((r) => r.id) } });
this.log.info(`Notification purge: deleted ${expired.length} row(s) older than ${retentionDays} days`, { cutoff });
}
});
};
//#endregion
//#region ../../src/api/notifications/schemas/notificationResourceSchema.ts
const notificationResourceSchema = z.object({
id: z.uuid(),
createdAt: z.datetime(),
status: z.text(),
template: z.text().optional(),
type: z.text().optional(),
contact: z.text().optional(),
category: z.text().optional(),
critical: z.boolean().optional(),
sensitive: z.boolean().optional(),
startedAt: z.datetime().optional(),
completedAt: z.datetime().optional(),
error: z.text().optional()
});
//#endregion
//#region ../../src/api/notifications/schemas/notificationDetailResourceSchema.ts
const notificationDetailResourceSchema = notificationResourceSchema.extend({
variables: z.record(z.text(), z.any()).optional(),
rendered: z.record(z.text(), z.any()).optional(),
logs: z.array(logEntrySchema).optional()
}).meta({
title: "NotificationDetailResource",
description: "A notification resource with rendered content and logs."
});
//#endregion
//#region ../../src/api/notifications/schemas/notificationQuerySchema.ts
const notificationQuerySchema = pageQuerySchema.extend({ status: z.enum([
"pending",
"scheduled",
"retrying",
"running",
"completed",
"dead",
"cancelled"
]).optional() });
//#endregion
//#region ../../src/api/notifications/controllers/AdminNotificationController.ts
var AdminNotificationController = class {
url = "/notifications";
group = "admin:notifications";
alepha = $inject(Alepha);
notificationJobs = $inject(NotificationJobs);
executions = $repository(jobExecutionEntity);
get jobName() {
return this.notificationJobs.sendNotification.name;
}
/**
* The tenant this request is acting in, when multi-tenant. The notification
* outbox (`job_executions`) is shared across tenants in a pooled worker, so
* every read/delete here is scoped to this org to prevent cross-tenant access.
* Undefined in single-tenant apps → no extra filter (all rows are this app's).
*/
get organizationId() {
return this.alepha.store.get(currentTenantAtom)?.id;
}
/** True when `exec` belongs to the acting tenant (or the app is single-tenant). */
sameTenant(exec) {
const org = this.organizationId;
return !org || exec.organizationId === org;
}
findNotifications = $action({
path: this.url,
group: this.group,
use: [$secure({ permissions: ["admin:notification:read"] })],
schema: {
query: notificationQuerySchema,
response: z.page(notificationResourceSchema)
},
handler: async ({ query }) => {
query.sort ??= "-createdAt";
const where = this.executions.createQueryWhere();
where.jobName = { eq: this.jobName };
const org = this.organizationId;
if (org) where.organizationId = { eq: org };
const page = await this.executions.paginate(query, { where }, { count: true });
return {
...page,
content: page.content.map((exec) => this.toResource(exec))
};
}
});
getNotification = $action({
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:notification:read"] })],
schema: {
params: z.object({ id: z.uuid() }),
response: notificationDetailResourceSchema
},
handler: async ({ params }) => {
const exec = await this.executions.findById(params.id);
if (!exec || exec.jobName !== this.jobName || !this.sameTenant(exec)) throw new NotFoundError(`Notification not found: ${params.id}`);
return this.toDetailResource(exec);
}
});
deleteNotification = $action({
method: "DELETE",
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:notification:delete"] })],
description: "Delete a notification record",
schema: {
params: z.object({ id: z.uuid() }),
response: okSchema
},
handler: async ({ params }) => {
const exec = await this.executions.findById(params.id);
if (!exec || exec.jobName !== this.jobName || !this.sameTenant(exec)) throw new NotFoundError(`Notification not found: ${params.id}`);
await this.executions.deleteById(params.id);
return {
ok: true,
id: params.id
};
}
});
deleteNotifications = $action({
method: "POST",
path: `${this.url}/delete`,
group: this.group,
use: [$secure({ permissions: ["admin:notification:delete"] })],
description: "Delete many notification records in one call",
schema: {
body: z.object({ ids: z.array(z.uuid()).min(1).max(1e3) }),
response: z.object({ deleted: z.array(z.uuid()) })
},
handler: async ({ body }) => {
const org = this.organizationId;
return { deleted: (await this.executions.deleteMany({
id: { inArray: body.ids },
jobName: { eq: this.jobName },
...org ? { organizationId: { eq: org } } : {}
})).map(String) };
}
});
toResource(exec) {
const payload = exec.payload ?? {};
return {
id: exec.id,
createdAt: exec.createdAt,
status: exec.status,
template: payload.template,
type: payload.type,
contact: payload.contact,
category: payload.category,
critical: payload.critical,
sensitive: payload.sensitive,
startedAt: exec.startedAt,
completedAt: exec.completedAt,
error: exec.error
};
}
toDetailResource(exec) {
const payload = exec.payload ?? {};
return {
...this.toResource(exec),
variables: payload.variables,
logs: exec.logs
};
}
};
//#endregion
//#region ../../src/api/notifications/schemas/notificationContactPreferencesSchema.ts
const notificationContactPreferencesSchema = z.object({
language: z.text().optional(),
exclude: z.array(z.text())
});
//#endregion
//#region ../../src/api/notifications/schemas/notificationContactSchema.ts
const notificationContactSchema = z.object({
email: z.email().optional(),
phoneNumber: z.e164().optional(),
firstName: z.shortText().optional(),
lastName: z.text({ size: "short" }).optional(),
language: z.bcp47().optional()
});
//#endregion
//#region ../../src/api/notifications/index.ts
/**
* User notification management.
*
* **Features:**
* - Notification definitions (email/SMS templates)
* - Delivery via `$job` with retry and audit trail (`record: "all"` + no ring buffer trim)
* - Runtime-editable retention window via `$parameter` — purge cron respects it live
* - Admin API for inspecting sent notifications
*
* **Delivery mode** is decided at runtime by the `$job` system:
* - If your app loads `AlephaApiJobsQueue` (and thus `AlephaQueue`), notifications
* go through the queue (best for high-volume systems).
* - Otherwise, notifications run in **direct** mode: pushed to the outbox table
* and processed in the same process right after the HTTP response is returned.
* The reconciliation sweep is the safety net for crashes / retries.
*
* Direct mode is the recommended default for small / cheap deployments
* (Cloudflare Workers, single-instance Node) — no queue infrastructure required.
*
* @module alepha.api.notifications
*/
const AlephaApiNotifications = $module({
name: "alepha.api.notifications",
imports: [AlephaApiJobs, AlephaApiParameters],
primitives: [$notification],
services: [
NotificationSenderService,
NotificationJobs,
AdminNotificationController
]
});
//#endregion
export { $notification, AdminNotificationController, AlephaApiNotifications, NotificationJobs, NotificationPrimitive, NotificationSenderService, notificationContactPreferencesSchema, notificationContactSchema, notificationDetailResourceSchema, notificationPayloadSchema, notificationQuerySchema, notificationResourceSchema };
//# sourceMappingURL=index.js.map