UNPKG

alepha

Version:

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

482 lines (481 loc) 17.2 kB
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