diff --git a/apps/blog-next/server/notifications/auth/email-verification.ts b/apps/blog-next/server/notifications/auth/email-verification.ts new file mode 100644 index 0000000..269a950 --- /dev/null +++ b/apps/blog-next/server/notifications/auth/email-verification.ts @@ -0,0 +1,31 @@ +import { defineNotification } from '@holo-js/notifications' + +interface EmailVerificationNotification { + readonly email: string + readonly name?: string + readonly url: string + readonly expiresAt: Date +} + +export default defineNotification({ + type: 'auth.email-verification', + via() { + return ['email'] + }, + build: { + email(data: EmailVerificationNotification) { + return { + subject: 'Verify your email address', + greeting: data.name ? `Hello ${data.name},` : undefined, + lines: [ + 'Confirm your account to finish signing in.', + `This verification link expires at ${data.expiresAt.toUTCString()}.`, + ], + action: { + label: 'Verify email address', + url: data.url, + }, + } + }, + }, +}) diff --git a/apps/blog-next/server/notifications/auth/password-reset.ts b/apps/blog-next/server/notifications/auth/password-reset.ts new file mode 100644 index 0000000..3ff27e3 --- /dev/null +++ b/apps/blog-next/server/notifications/auth/password-reset.ts @@ -0,0 +1,29 @@ +import { defineNotification } from '@holo-js/notifications' + +interface PasswordResetNotification { + readonly email: string + readonly url: string + readonly expiresAt: Date +} + +export default defineNotification({ + type: 'auth.password-reset', + via() { + return ['email'] + }, + build: { + email(data: PasswordResetNotification) { + return { + subject: 'Reset your password', + lines: [ + 'Click the link below to choose a new password.', + `This reset link expires at ${data.expiresAt.toUTCString()}.`, + ], + action: { + label: 'Reset password', + url: data.url, + }, + } + }, + }, +}) diff --git a/apps/blog-nuxt/server/notifications/auth/email-verification.ts b/apps/blog-nuxt/server/notifications/auth/email-verification.ts new file mode 100644 index 0000000..269a950 --- /dev/null +++ b/apps/blog-nuxt/server/notifications/auth/email-verification.ts @@ -0,0 +1,31 @@ +import { defineNotification } from '@holo-js/notifications' + +interface EmailVerificationNotification { + readonly email: string + readonly name?: string + readonly url: string + readonly expiresAt: Date +} + +export default defineNotification({ + type: 'auth.email-verification', + via() { + return ['email'] + }, + build: { + email(data: EmailVerificationNotification) { + return { + subject: 'Verify your email address', + greeting: data.name ? `Hello ${data.name},` : undefined, + lines: [ + 'Confirm your account to finish signing in.', + `This verification link expires at ${data.expiresAt.toUTCString()}.`, + ], + action: { + label: 'Verify email address', + url: data.url, + }, + } + }, + }, +}) diff --git a/apps/blog-nuxt/server/notifications/auth/password-reset.ts b/apps/blog-nuxt/server/notifications/auth/password-reset.ts new file mode 100644 index 0000000..3ff27e3 --- /dev/null +++ b/apps/blog-nuxt/server/notifications/auth/password-reset.ts @@ -0,0 +1,29 @@ +import { defineNotification } from '@holo-js/notifications' + +interface PasswordResetNotification { + readonly email: string + readonly url: string + readonly expiresAt: Date +} + +export default defineNotification({ + type: 'auth.password-reset', + via() { + return ['email'] + }, + build: { + email(data: PasswordResetNotification) { + return { + subject: 'Reset your password', + lines: [ + 'Click the link below to choose a new password.', + `This reset link expires at ${data.expiresAt.toUTCString()}.`, + ], + action: { + label: 'Reset password', + url: data.url, + }, + } + }, + }, +}) diff --git a/apps/blog-sveltekit/server/notifications/auth/email-verification.ts b/apps/blog-sveltekit/server/notifications/auth/email-verification.ts new file mode 100644 index 0000000..269a950 --- /dev/null +++ b/apps/blog-sveltekit/server/notifications/auth/email-verification.ts @@ -0,0 +1,31 @@ +import { defineNotification } from '@holo-js/notifications' + +interface EmailVerificationNotification { + readonly email: string + readonly name?: string + readonly url: string + readonly expiresAt: Date +} + +export default defineNotification({ + type: 'auth.email-verification', + via() { + return ['email'] + }, + build: { + email(data: EmailVerificationNotification) { + return { + subject: 'Verify your email address', + greeting: data.name ? `Hello ${data.name},` : undefined, + lines: [ + 'Confirm your account to finish signing in.', + `This verification link expires at ${data.expiresAt.toUTCString()}.`, + ], + action: { + label: 'Verify email address', + url: data.url, + }, + } + }, + }, +}) diff --git a/apps/blog-sveltekit/server/notifications/auth/password-reset.ts b/apps/blog-sveltekit/server/notifications/auth/password-reset.ts new file mode 100644 index 0000000..3ff27e3 --- /dev/null +++ b/apps/blog-sveltekit/server/notifications/auth/password-reset.ts @@ -0,0 +1,29 @@ +import { defineNotification } from '@holo-js/notifications' + +interface PasswordResetNotification { + readonly email: string + readonly url: string + readonly expiresAt: Date +} + +export default defineNotification({ + type: 'auth.password-reset', + via() { + return ['email'] + }, + build: { + email(data: PasswordResetNotification) { + return { + subject: 'Reset your password', + lines: [ + 'Click the link below to choose a new password.', + `This reset link expires at ${data.expiresAt.toUTCString()}.`, + ], + action: { + label: 'Reset password', + url: data.url, + }, + } + }, + }, +}) diff --git a/apps/docs/docs/auth/email-verification.md b/apps/docs/docs/auth/email-verification.md index 8b4912c..728af97 100644 --- a/apps/docs/docs/auth/email-verification.md +++ b/apps/docs/docs/auth/email-verification.md @@ -1,15 +1,19 @@ # Email Verification -Email verification lets the application require a verified address while keeping the delivery flow automatic. +Email verification lets an application require a verified address while Holo handles token creation, delivery, and +verification-link generation. ## Introduction -When email verification is enabled: +When email verification is enabled, the framework owns the token lifecycle and the application owns the HTTP routes and +pages that users interact with. -- registration automatically creates and sends a verification email -- login is still allowed -- the returned session tells the route that verification is still required -- the framework-generated email link uses `APP_URL` plus the configured verification route +The usual flow is: + +- `register(...)` creates the user and sends the first verification email automatically +- `login(...)` can still succeed for unverified users +- the login result tells the route when the user should be sent to the verification page +- the emailed link is built from `APP_URL` plus the configured verification route Enable it in `config/auth.ts`: @@ -24,7 +28,7 @@ export default defineAuthConfig({ }) ``` -The local model should have an `email_verified_at` column. +The local user model should have an `email_verified_at` column. ## Environment @@ -38,106 +42,207 @@ AUTH_EMAIL_VERIFICATION_ROUTE=/verify-email `APP_URL` is used when the framework builds the email link. Applications should not manually construct the verification URL in normal usage. -The application still owns the verification page and the route that calls `verifyEmail(token)`. The framework -owns the redirect target and the generated email link. +The application owns the page and API routes. The framework owns token storage, delivery, and the generated email link. + +## Form Schemas + +Use form schemas for request payloads so route handlers receive typed, validated data before calling auth: + +```ts +import { field, schema } from '@holo-js/forms/schema' + +export const verifyEmailForm = schema({ + token: field.string().required('Verification token is required.'), +}) + +export const resendEmailVerificationForm = schema({ + email: field.string().required('Email is required.').email('Enter a valid email address.'), +}) +``` ## Registration Flow -Registration automatically starts email verification when `emailVerification.required` is `true`: +Registration automatically starts email verification when `emailVerification.required` is `true`. Validate the request, +then pass the typed form data to `register(...)`: ```ts import { register } from '@holo-js/auth' +import { validate } from '@holo-js/forms' -const { data: created, error } = await register({ - name: body.name, - email: body.email, - password: body.password, - passwordConfirmation: body.passwordConfirmation, -}) +import { registerForm } from '@/lib/schemas/auth' + +export async function POST(request: Request) { + const submission = await validate(request, registerForm) + + if (!submission.valid) { + return Response.json(submission.fail(), { + status: submission.fail().status, + }) + } + + const { data: session, error } = await register(submission.data) + + if (error) { + const failure = submission.fail({ + status: error.status, + errors: error.fields, + }) + + return Response.json(failure, { status: failure.status }) + } + + return Response.json(submission.success({ + message: session.emailVerificationRequired + ? 'Account created. Check your email to verify your address.' + : 'Account created.', + redirectTo: session.emailVerificationRequired + ? session.emailVerificationRoute ?? '/verify-email' + : '/admin', + })) +} ``` Expected registration failures come back in `error`. On success, the local user is created and the verification message is delivered automatically through the configured auth delivery integration. -Applications do not need to call `verification.create(...)` after registration just to send the first email. +::: tip Automatic verification email +Applications do not need to send the first verification email manually after registration. When +`emailVerification.required` is enabled, a successful `register(...)` call starts the verification delivery flow. +::: ## Login Flow -Unverified users can still sign in. The returned session includes verification state: +Unverified users can still sign in. Validate the login payload, then inspect the returned session: ```ts import { login } from '@holo-js/auth' +import { validate } from '@holo-js/forms' -const { data: session, error } = await login({ - email: body.email, - password: body.password, - remember: body.remember === true, -}) -``` +import { loginForm } from '@/lib/schemas/auth' -When verification is still required, successful login includes: +export async function POST(request: Request) { + const submission = await validate(request, loginForm) -- `emailVerificationRequired: true` -- `emailVerificationRoute: '/verify-email?email=ava%40example.com'` + if (!submission.valid) { + return Response.json(submission.fail(), { + status: submission.fail().status, + }) + } -Typical route handling: + const { data: session, error } = await login(submission.data) -```ts -if (error) { - return Response.json({ - ok: false, - status: error.status, - valid: false, - values: body, - errors: error.fields, - }, { status: error.status }) -} + if (error) { + const failure = submission.fail({ + status: error.status, + errors: error.fields, + }) + + return Response.json(failure, { status: failure.status }) + } -return Response.json({ - ok: true, - data: { + return Response.json(submission.success({ message: session.emailVerificationRequired ? 'Signed in. Verify your email address to continue.' : 'Signed in successfully.', redirectTo: session.emailVerificationRequired ? session.emailVerificationRoute ?? '/verify-email' : '/admin', - }, -}) + })) +} ``` +When verification is still required, successful login includes: + +- `emailVerificationRequired: true` +- `emailVerificationRoute: '/verify-email?email=ava%40example.com'` + That lets the app redirect the signed-in user to the verify page instead of rejecting the login attempt. ## Consuming Verification Tokens -Verification pages verify the token from the emailed link: +Verification pages submit the token from the emailed link. Validate the payload, then call `verifyEmail(token)`: ```ts import { verifyEmail } from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { verifyEmailForm } from '@/lib/schemas/auth' -const { data: verifiedUser, error } = await verifyEmail(token) +export async function POST(request: Request) { + const submission = await validate(request, verifyEmailForm) + + if (!submission.valid) { + return Response.json(submission.fail(), { + status: submission.fail().status, + }) + } + + const { data: verifiedUser, error } = await verifyEmail(submission.data.token) + + if (error) { + const failure = submission.fail({ + status: error.status, + errors: error.fields, + }) + + return Response.json(failure, { status: failure.status }) + } + + return Response.json(submission.success({ + message: 'Email verified.', + user: verifiedUser, + })) +} ``` The verification flow marks the local user as verified and invalidates the token. ## Resending Verification Emails -Applications can resend another verification email with a direct email argument: +The verify page can submit an email address to request a fresh verification email. Validate the resend payload, then pass +the typed email string to `resendEmailVerification(email)`: ```ts import { resendEmailVerification } from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { resendEmailVerificationForm } from '@/lib/schemas/auth' -const { error } = await resendEmailVerification(body.email) +export async function POST(request: Request) { + const submission = await validate(request, resendEmailVerificationForm) + + if (!submission.valid) { + return Response.json(submission.fail(), { + status: submission.fail().status, + }) + } + + const { error } = await resendEmailVerification(submission.data.email) + + if (error) { + const failure = submission.fail({ + status: error.status, + errors: error.fields, + }) + + return Response.json(failure, { status: failure.status }) + } + + return Response.json(submission.success({ + message: 'A fresh verification email has been sent.', + })) +} ``` -This is the intended verify-page flow when the user lands on `/verify-email?email=...` after login. +This is the intended verify-page flow when the user lands on `/verify-email?email=...` after login. The route receives +validated form data, so it can pass `submission.data.email` directly to auth without manual body parsing. -When you are sending a verification email outside a resend route, use the same API shape with the send-oriented name: +When the route is not specifically a resend action, use the same parameter shape with the send-oriented name: ```ts import { sendEmailVerification } from '@holo-js/auth' -const { error } = await sendEmailVerification(body.email) +const { error } = await sendEmailVerification(email) ``` Expected resend failures come back in `error`, for example: @@ -149,11 +254,34 @@ Expected resend failures come back in `error`, for example: Verification delivery is automatic once auth delivery is available. -- if `@holo-js/notifications` is installed, core can route auth delivery through notifications -- if notifications are absent but `@holo-js/mail` is installed, core can send directly through mail -- if no delivery integration is installed, auth logs the skipped delivery instead of building links in app code +- if `@holo-js/notifications` is installed with mail support, core routes auth delivery through notifications +- if notifications are absent but `@holo-js/mail` is installed, core sends directly through mail +- if no delivery integration is installed, auth creates the token and logs the skipped delivery -The generated verification email includes: +When auth and notifications are scaffolded together, Holo creates editable notification files: + +```txt +server/notifications/auth/email-verification.ts +server/notifications/auth/password-reset.ts +``` + +Existing applications can publish those files later: + +```bash +npx holo auth:notifications:publish +``` + +The published verification notification is a normal `defineNotification(...)` file. Its email builder receives a small +app-facing data with `email`, optional `name`, generated `url`, and `expiresAt`. Edit the file to change the subject, +body, action text, queue settings, or delay behavior. + +::: warning Delivery package required +Publishing notification files only gives the application editable message definitions. Email delivery still needs +`@holo-js/mail` or another configured notification mailer. Without delivery, auth creates the verification token and +logs that the email was skipped. +::: + +The default verification email includes: - an HTML body - a text fallback @@ -163,6 +291,9 @@ The generated verification email includes: Applications do not need to manually compose `https://app.test/verify-email?token=...` links in normal usage. +Verification emails are queued only when the configured notifications or mail runtime is queued. Auth itself does not +force queueing. + ## Protecting Application Routes Route protection still belongs to the application. Email verification does not automatically block arbitrary pages. diff --git a/apps/docs/docs/auth/local-auth.md b/apps/docs/docs/auth/local-auth.md index 24ecdff..2cc88d2 100644 --- a/apps/docs/docs/auth/local-auth.md +++ b/apps/docs/docs/auth/local-auth.md @@ -167,7 +167,7 @@ and `dob` are saved as attributes, but they are not treated as auth identifiers provider's `identifiers`. When `emailVerification.required` is `true`, successful registration also starts the verification flow automatically. -Applications do not need to manually call `verification.create(...)` just to send the first verification email. +Applications do not need to manually send the first verification email. If your application uses another identifier, pass that identifier instead: diff --git a/apps/docs/docs/auth/password-reset.md b/apps/docs/docs/auth/password-reset.md index 9441041..30b289d 100644 --- a/apps/docs/docs/auth/password-reset.md +++ b/apps/docs/docs/auth/password-reset.md @@ -102,8 +102,30 @@ Password reset delivery works the same way as email verification: - core builds the reset URL automatically from `APP_URL` and the configured broker route - notifications or direct mail deliver the message when those integrations are installed -If `@holo-js/auth` and `@holo-js/notifications` are both installed, core bridges the built-in auth delivery hook -through notifications automatically. If notifications are absent but `@holo-js/mail` is installed, core falls -back to direct mail delivery. +If `@holo-js/auth` and `@holo-js/notifications` are both installed with mail support, core bridges auth delivery +through notifications automatically. If notifications are absent but `@holo-js/mail` is installed, core falls back +to direct mail delivery. + +When auth and notifications are scaffolded together, Holo creates editable notification files: + +```txt +server/notifications/auth/email-verification.ts +server/notifications/auth/password-reset.ts +``` + +Existing applications can publish those files later: + +```bash +npx holo auth:notifications:publish +``` + +The published password reset notification is a normal `defineNotification(...)` file. Its email builder receives a +small app-facing data with `email`, generated `url`, and `expiresAt`. + +::: warning Delivery package required +Publishing notification files only gives the application editable message definitions. Email delivery still needs +`@holo-js/mail` or another configured notification mailer. Without delivery, auth creates the reset token and logs that +the email was skipped. +::: Applications do not need to manually create `reset-password?token=...` URLs in normal usage. diff --git a/apps/docs/docs/mail/notifications.md b/apps/docs/docs/mail/notifications.md index f21ae53..a0fe8f9 100644 --- a/apps/docs/docs/mail/notifications.md +++ b/apps/docs/docs/mail/notifications.md @@ -22,20 +22,24 @@ You can customize how notifications are converted to mails by defining a custom ```ts import { defineNotification } from '@holo-js/notifications' +interface InvoicePaidNotification { + readonly invoiceNumber: string +} + const invoicePaid = defineNotification({ type: 'invoice-paid', via() { - return ['email'] as const + return ['email'] }, build: { - email() { + email(data: InvoicePaidNotification) { return { - subject: 'Invoice Paid', - markdown: `# Invoice Paid\n\nYour invoice has been successfully paid.`, + subject: `Invoice #${data.invoiceNumber} paid`, + markdown: `# Invoice Paid\n\nInvoice #${data.invoiceNumber} has been successfully paid.`, // You can also specify mail-specific options here // like attachments, cc/bcc, etc. } - } - } + }, + }, }) -``` \ No newline at end of file +``` diff --git a/apps/docs/docs/notifications.md b/apps/docs/docs/notifications.md index 63bf998..25d3303 100644 --- a/apps/docs/docs/notifications.md +++ b/apps/docs/docs/notifications.md @@ -11,33 +11,46 @@ Notifications are defined using the `defineNotification` function. Each notifica ```ts import { defineNotification } from '@holo-js/notifications' +interface InvoicePaidNotification { + readonly id: string + readonly type: string + readonly email: string + readonly invoiceId: string + readonly invoiceNumber: string + readonly paidAt: string +} + const invoicePaid = defineNotification({ type: 'invoice-paid', via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, build: { - email() { + email(data: InvoicePaidNotification) { return { - subject: 'Invoice Paid', - lines: ['Your invoice has been successfully paid.'] + subject: `Invoice #${data.invoiceNumber} paid`, + lines: ['Your invoice has been successfully paid.'], } }, - database() { + database(data: InvoicePaidNotification) { return { - status: 'paid', - paidAt: new Date().toISOString() + data: { + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + paidAt: data.paidAt, + }, } }, - broadcast() { + broadcast(data: InvoicePaidNotification) { return { event: 'invoice.paid', data: { - status: 'paid' - } + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + }, } - } - } + }, + }, }) ``` @@ -54,7 +67,8 @@ The `via()` method returns an array of channel names that the notification shoul ### Building Channel-Specific Data -For each channel specified in `via()`, you must provide a corresponding builder function in the `build` object. These functions return the specific data that should be sent through each channel. +For each channel specified in `via()`, you must provide a corresponding builder function in the `build` object. The +builder receives the same data object passed to `notify(...)` and returns the data sent through that channel. ## Sending Notifications @@ -66,7 +80,14 @@ Notifications are sent using the `notify` function, which returns a fluent API f import { notify } from '@holo-js/notifications' import { invoicePaid } from './notifications' -await notify(user, invoicePaid) +await notify({ + id: 'user-1', + type: 'users', + email: 'ava@example.com', + invoiceId: 'inv-100', + invoiceNumber: 'INV-100', + paidAt: new Date().toISOString(), +}, invoicePaid) ``` ### Fluent Configuration Options @@ -130,8 +151,8 @@ await notifyUsing() email: 'admin@example.com' }) .channel('database', { - // For database channel, you might want to store it for a specific user - userId: '123' + id: 'user-1', + type: 'users', }) .notify(invoicePaid) ``` @@ -151,9 +172,10 @@ build: { 'Thanks for joining our platform!', 'We\'re excited to have you on board.' ], - // Optional: Add action buttons - actionText: 'Get Started', - actionUrl: 'https://example.com/get-started' + action: { + label: 'Get Started', + url: 'https://example.com/get-started', + }, } } } @@ -161,23 +183,25 @@ build: { Available email properties: - `subject` (required) - The email subject line -- `lines` (required) - Array of text lines for the email body -- `actionText` (optional) - Text for a call-to-action button -- `actionUrl` (optional) - URL for the call-to-action button -- `introLines` (optional) - Introductory lines before the main content -- `outroLines` (optional) - Concluding lines after the main content +- `lines` (optional) - Array of text lines for the email body +- `greeting` (optional) - Greeting text shown before the lines +- `action` (optional) - Button label and URL +- `html` (optional) - HTML body override +- `text` (optional) - text body override ### Database Channel -For the database channel, your builder function should return an object that will be serialized and stored in the notifications table: +For the database channel, return a `data` object that will be serialized into the notifications table: ```ts build: { database() { return { - amount: 100.00, - transactionId: 'txn_123abc', - status: 'completed' + data: { + amount: 100.00, + transactionId: 'txn_123abc', + status: 'completed', + }, } } } @@ -235,7 +259,7 @@ Once registered, you can use your custom channel just like built-in channels: const welcomeNotification = defineNotification({ type: 'welcome', via() { - return ['email', 'slack'] as const + return ['email', 'slack'] }, build: { email() { @@ -293,24 +317,24 @@ Holo-JS provides helper functions for working with stored notifications: ```ts import { + deleteNotifications, listNotifications, - listUnreadNotifications, - markAsRead, - markAsUnread, - deleteNotifications -} from '@holo-js/notifications/database' + markNotificationsAsRead, + markNotificationsAsUnread, + unreadNotifications, +} from '@holo-js/notifications' // Get all notifications for a user -const notifications = await listNotifications({ userId: '123' }) +const notifications = await listNotifications({ id: 'user-1', type: 'users' }) // Get only unread notifications -const unread = await listUnreadNotifications({ userId: '123' }) +const unread = await unreadNotifications({ id: 'user-1', type: 'users' }) // Mark notifications as read -await markAsRead(['notif_1', 'notif_2', 'notif_3']) +await markNotificationsAsRead(['notif_1', 'notif_2', 'notif_3']) // Mark notifications as unread -await markAsUnread(['notif_4', 'notif_5']) +await markNotificationsAsUnread(['notif_4', 'notif_5']) // Delete notifications await deleteNotifications(['notif_6', 'notif_7']) @@ -401,4 +425,4 @@ You can override configuration values using environment variables: NOTIFICATIONS_DEFAULT=database NOTIFICATIONS_QUEUE_CONNECTION=redis NOTIFICATIONS_QUEUE_QUEUE=notifications -``` \ No newline at end of file +``` diff --git a/apps/docs/docs/notifications/creating-notifications.md b/apps/docs/docs/notifications/creating-notifications.md index 714826d..32af6ae 100644 --- a/apps/docs/docs/notifications/creating-notifications.md +++ b/apps/docs/docs/notifications/creating-notifications.md @@ -7,36 +7,99 @@ Each notification consists of a type identifier and channel-specific builders th ```ts import { defineNotification } from '@holo-js/notifications' +interface InvoicePaidNotification { + readonly id: string + readonly type: string + readonly email: string + readonly name?: string + readonly invoiceId: string + readonly invoiceNumber: string + readonly paidAt: string +} + const invoicePaid = defineNotification({ type: 'invoice-paid', via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, build: { - email() { + email(data: InvoicePaidNotification) { return { - subject: 'Invoice Paid', - lines: ['Your invoice has been successfully paid.'] + subject: `Invoice #${data.invoiceNumber} paid`, + greeting: data.name ? `Hello ${data.name},` : undefined, + lines: ['Your invoice has been successfully paid.'], } }, - database() { + database(data: InvoicePaidNotification) { return { - status: 'paid', - paidAt: new Date().toISOString() + data: { + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + paidAt: data.paidAt, + }, } }, - broadcast() { + broadcast(data: InvoicePaidNotification) { return { event: 'invoice.paid', data: { - status: 'paid' - } + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + }, } - } - } + }, + }, }) ``` +## Passing Data To A Notification + +The value passed to `notify(...)` is the same value received by `via(...)` and every channel builder. Put the route +fields and message variables the notification needs on that object. + +```ts +import { defineNotification, notify } from '@holo-js/notifications' + +interface InvoicePaidNotification { + readonly id: string + readonly type: string + readonly email: string + readonly name?: string + readonly invoiceId: string + readonly invoiceNumber: string +} + +const invoicePaid = defineNotification({ + type: 'invoice-paid', + via() { + return ['email'] + }, + build: { + email(data: InvoicePaidNotification) { + return { + subject: `Invoice #${data.invoiceNumber} paid`, + greeting: data.name ? `Hello ${data.name},` : undefined, + action: { + label: 'View invoice', + url: `https://app.test/invoices/${data.invoiceId}`, + }, + } + }, + }, +}) + +const notificationInput = { + id: 'user-1', + type: 'users', + name: 'Ava', + email: 'ava@example.com', + invoiceId: 'inv-100', + invoiceNumber: 'INV-100', +} + +await notify(notificationInput, invoicePaid) +``` + ## Notification Types Each notification must have a unique `type` string that identifies it. This type is used when storing notifications in the database and can be used for filtering or processing notifications programmatically. @@ -50,4 +113,4 @@ The `via()` method returns an array of channel names that the notification shoul ## Building Channel-Specific Data -For each channel specified in `via()`, you must provide a corresponding builder function in the `build` object. These functions return the specific data that should be sent through each channel. \ No newline at end of file +For each channel specified in `via()`, you must provide a corresponding builder function in the `build` object. These functions return the specific data that should be sent through each channel. diff --git a/apps/docs/docs/notifications/custom-channels.md b/apps/docs/docs/notifications/custom-channels.md index 5a5e91e..893afaf 100644 --- a/apps/docs/docs/notifications/custom-channels.md +++ b/apps/docs/docs/notifications/custom-channels.md @@ -51,7 +51,7 @@ import { defineNotification, notifyUsing } from '@holo-js/notifications' const deploymentFinished = defineNotification({ type: 'deployment-finished', via() { - return ['slack'] as const + return ['slack'] }, build: { slack() { diff --git a/apps/docs/docs/notifications/defining-notifications.md b/apps/docs/docs/notifications/defining-notifications.md index b5cf44a..81b2871 100644 --- a/apps/docs/docs/notifications/defining-notifications.md +++ b/apps/docs/docs/notifications/defining-notifications.md @@ -14,59 +14,132 @@ Each notification controls: ```ts import { defineNotification } from '@holo-js/notifications' -export const invoicePaid = (invoice: { - id: string - number: string - total: number -}) => defineNotification({ +interface InvoiceRecipient { + readonly id: string + readonly type: string + readonly name?: string + readonly email: string + readonly broadcastChannels?: readonly string[] + readonly invoiceId: string + readonly invoiceNumber: string + readonly invoiceTotal: number +} + +export const invoicePaid = defineNotification({ type: 'invoice-paid', via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, build: { - email(user: { name?: string }) { + email(data: InvoiceRecipient) { return { - subject: `Invoice #${invoice.number} paid`, - greeting: `Hello ${user.name ?? 'there'},`, + subject: `Invoice #${data.invoiceNumber} paid`, + greeting: data.name ? `Hello ${data.name},` : undefined, lines: [ - `Invoice #${invoice.number} has been paid.`, - `Total: ${invoice.total}.`, + `Invoice #${data.invoiceNumber} has been paid.`, + `Total: ${data.invoiceTotal}.`, ], action: { label: 'View invoice', - url: `https://app.test/invoices/${invoice.id}`, + url: `https://app.test/invoices/${data.invoiceId}`, }, } }, - database() { + database(data: InvoiceRecipient) { return { data: { - invoiceId: invoice.id, - invoiceNumber: invoice.number, - total: invoice.total, - message: `Invoice #${invoice.number} has been paid.`, + userId: data.id, + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + total: data.invoiceTotal, + message: `Invoice #${data.invoiceNumber} has been paid.`, }, } }, - broadcast(user: { id: string }) { + broadcast(data: InvoiceRecipient) { return { event: 'notifications.invoice-paid', data: { - invoiceId: invoice.id, - invoiceNumber: invoice.number, - total: invoice.total, - userId: user.id, + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + total: data.invoiceTotal, + userId: data.id, + }, + } + }, + }, +}) +``` + +## Passing Variables + +The value passed to `notify(...)` is the same value passed to `via(...)` and to each channel builder. Include the +channel route fields and the message variables on that data object. + +```ts +import { defineNotification, notify } from '@holo-js/notifications' + +interface InvoiceRecipient { + readonly id: string + readonly type: string + readonly name?: string + readonly email?: string + readonly invoiceId: string + readonly invoiceNumber: string + readonly invoiceTotal: number +} + +const invoicePaid = defineNotification({ + type: 'invoice-paid', + via(data: InvoiceRecipient) { + return data.email ? ['email', 'database'] : ['database'] + }, + build: { + email(data: InvoiceRecipient) { + return { + subject: `Invoice #${data.invoiceNumber} paid`, + greeting: data.name ? `Hello ${data.name},` : undefined, + lines: [ + `Invoice #${data.invoiceNumber} has been paid.`, + `Total: ${data.invoiceTotal}.`, + ], + action: { + label: 'View invoice', + url: `https://app.test/invoices/${data.invoiceId}`, + }, + } + }, + database(data: InvoiceRecipient) { + return { + data: { + userId: data.id, + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + total: data.invoiceTotal, }, } }, }, }) + +await notify({ + id: 'user-1', + type: 'users', + name: 'Ava', + email: 'ava@example.com', + invoiceId: 'inv-100', + invoiceNumber: 'INV-100', + invoiceTotal: 250, +}, invoicePaid) ``` +In this example, the same object supplies the email route, the database notifiable route, and the invoice variables +used by the message builders. + ## Built-in channel payloads The built-in channels expect these payload families: @@ -80,7 +153,7 @@ The simplest valid payloads are: ```ts defineNotification({ via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, build: { email() { @@ -113,7 +186,7 @@ Notifications can declare queueing and delay defaults directly: ```ts defineNotification({ via() { - return ['email', 'database'] as const + return ['email', 'database'] }, queue: { connection: 'redis', diff --git a/apps/docs/docs/notifications/index.md b/apps/docs/docs/notifications/index.md index 1cff22d..f07fcef 100644 --- a/apps/docs/docs/notifications/index.md +++ b/apps/docs/docs/notifications/index.md @@ -24,44 +24,55 @@ but the real sender implementations stay outside this package. ```ts import { defineNotification, notify } from '@holo-js/notifications' -const invoicePaid = (invoice: { id: string, number: string, total: number }) => defineNotification({ +interface InvoiceRecipient { + readonly id: string + readonly type: string + readonly name?: string + readonly email: string + readonly invoiceId: string + readonly invoiceNumber: string + readonly invoiceTotal: number +} + +const invoicePaid = defineNotification({ type: 'invoice-paid', via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, build: { - email(user: { name?: string }) { + email(data: InvoiceRecipient) { return { - subject: `Invoice #${invoice.number} paid`, - greeting: `Hello ${user.name ?? 'there'},`, + subject: `Invoice #${data.invoiceNumber} paid`, + greeting: data.name ? `Hello ${data.name},` : undefined, lines: [ - `Invoice #${invoice.number} has been paid.`, - `Total: ${invoice.total}.`, + `Invoice #${data.invoiceNumber} has been paid.`, + `Total: ${data.invoiceTotal}.`, ], action: { label: 'View invoice', - url: `https://app.test/invoices/${invoice.id}`, + url: `https://app.test/invoices/${data.invoiceId}`, }, } }, - database() { + database(data: InvoiceRecipient) { return { data: { - invoiceId: invoice.id, - invoiceNumber: invoice.number, - total: invoice.total, - message: `Invoice #${invoice.number} has been paid.`, + userId: data.id, + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + total: data.invoiceTotal, + message: `Invoice #${data.invoiceNumber} has been paid.`, }, } }, - broadcast(user: { id: string }) { + broadcast(data: InvoiceRecipient) { return { event: 'notifications.invoice-paid', data: { - invoiceId: invoice.id, - invoiceNumber: invoice.number, - total: invoice.total, - userId: user.id, + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + total: data.invoiceTotal, + userId: data.id, }, } }, @@ -70,15 +81,18 @@ const invoicePaid = (invoice: { id: string, number: string, total: number }) => await notify({ id: 'user-1', + type: 'users', name: 'Ava', email: 'ava@example.com', -}, invoicePaid({ - id: 'inv-100', - number: 'INV-100', - total: 250, -})) + invoiceId: 'inv-100', + invoiceNumber: 'INV-100', + invoiceTotal: 250, +}, invoicePaid) ``` +The object passed to `notify(...)` is passed into `via(...)` and every channel builder. Include the route fields and +message variables the notification needs on that object. + ## Package boundaries - `@holo-js/notifications` owns notification contracts, channel contracts, and dispatch orchestration. diff --git a/apps/docs/docs/notifications/notification-channels.md b/apps/docs/docs/notifications/notification-channels.md index f22f944..53c1411 100644 --- a/apps/docs/docs/notifications/notification-channels.md +++ b/apps/docs/docs/notifications/notification-channels.md @@ -13,9 +13,10 @@ build: { 'Thanks for joining our platform!', 'We\'re excited to have you on board.' ], - // Optional: Add action buttons - actionText: 'Get Started', - actionUrl: 'https://example.com/get-started' + action: { + label: 'Get Started', + url: 'https://example.com/get-started', + }, } } } @@ -23,23 +24,25 @@ build: { Available email properties: - `subject` (required) - The email subject line -- `lines` (required) - Array of text lines for the email body -- `actionText` (optional) - Text for a call-to-action button -- `actionUrl` (optional) - URL for the call-to-action button -- `introLines` (optional) - Introductory lines before the main content -- `outroLines` (optional) - Concluding lines after the main content +- `lines` (optional) - Array of text lines for the email body +- `greeting` (optional) - Greeting text shown before the lines +- `action` (optional) - Button label and URL +- `html` (optional) - HTML body override +- `text` (optional) - text body override ## Database Channel -For the database channel, your builder function should return an object that will be serialized and stored in the notifications table: +For the database channel, return a `data` object that will be serialized into the notifications table: ```ts build: { database() { return { - amount: 100.00, - transactionId: 'txn_123abc', - status: 'completed' + data: { + amount: 100.00, + transactionId: 'txn_123abc', + status: 'completed', + }, } } } @@ -65,4 +68,4 @@ build: { } ``` -The `event` property determines the websocket event name, and `data` contains the payload that will be sent to subscribers. \ No newline at end of file +The `event` property determines the websocket event name, and `data` contains the payload that will be sent to subscribers. diff --git a/apps/docs/docs/notifications/notification-storage.md b/apps/docs/docs/notifications/notification-storage.md index a704b7f..c10c26f 100644 --- a/apps/docs/docs/notifications/notification-storage.md +++ b/apps/docs/docs/notifications/notification-storage.md @@ -17,59 +17,35 @@ Holo-JS provides helper functions for working with stored notifications: ```ts import { + deleteNotifications, listNotifications, - listUnreadNotifications, - markAsRead, - markAsUnread, - deleteNotifications -} from '@holo-js/notifications/database' + markNotificationsAsRead, + markNotificationsAsUnread, + unreadNotifications, +} from '@holo-js/notifications' // Get all notifications for a user -const notifications = await listNotifications({ userId: '123' }) +const notifications = await listNotifications({ id: 'user-1', type: 'users' }) // Get only unread notifications -const unread = await listUnreadNotifications({ userId: '123' }) +const unread = await unreadNotifications({ id: 'user-1', type: 'users' }) // Mark notifications as read -await markAsRead(['notif_1', 'notif_2', 'notif_3']) +await markNotificationsAsRead(['notif_1', 'notif_2', 'notif_3']) // Mark notifications as unread -await markAsUnread(['notif_4', 'notif_5']) +await markNotificationsAsUnread(['notif_4', 'notif_5']) // Delete notifications await deleteNotifications(['notif_6', 'notif_7']) ``` -## Querying Notifications - -You can filter notifications when listing them: - -```ts -// Get notifications by type -const invoices = await listNotifications({ - userId: '123', - type: 'invoice-paid' -}) - -// Get notifications created after a specific date -const recent = await listNotifications({ - userId: '123', - createdAfter: new Date(Date.now() - 86400000) // Last 24 hours -}) -``` - ## Marking as Read/Unread ```ts // Mark specific notifications as read -await markAsRead(['notif_1', 'notif_2', 'notif_3']) - -// Mark all notifications as read for a user -await markAsReadForUser('123') +await markNotificationsAsRead(['notif_1', 'notif_2', 'notif_3']) // Mark specific notifications as unread -await markAsUnread(['notif_4', 'notif_5']) - -// Mark all notifications as unread for a user -await markAsUnreadForUser('123') -``` \ No newline at end of file +await markNotificationsAsUnread(['notif_4', 'notif_5']) +``` diff --git a/apps/docs/docs/notifications/on-demand-notifications.md b/apps/docs/docs/notifications/on-demand-notifications.md index 432e50a..f7bc6ee 100644 --- a/apps/docs/docs/notifications/on-demand-notifications.md +++ b/apps/docs/docs/notifications/on-demand-notifications.md @@ -28,8 +28,8 @@ await notifyUsing() email: 'admin@example.com' }) .channel('database', { - // For database channel, you might want to store it for a specific user - userId: '123' + id: 'user-1', + type: 'users', }) .notify(invoicePaid) -``` \ No newline at end of file +``` diff --git a/apps/docs/docs/notifications/queueing-notifications.md b/apps/docs/docs/notifications/queueing-notifications.md index ed9e33d..068c1a1 100644 --- a/apps/docs/docs/notifications/queueing-notifications.md +++ b/apps/docs/docs/notifications/queueing-notifications.md @@ -55,42 +55,50 @@ await notify(user, invoicePaid) .delay(new Date(Date.now() + 3600000)) ``` -### Per-Channel Queue Settings +### Notification Queue Defaults -You can set queue defaults per channel in your notification definition: +You can set queue defaults for every queued channel in your notification definition: ```ts +interface InvoicePaidNotification { + readonly invoiceId: string + readonly invoiceNumber: string +} + const invoicePaid = defineNotification({ type: 'invoice-paid', via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, queue: { - email: 'notifications-high-priority', // Use different queue for email - database: 'notifications' // Use default queue for database + queue: 'notifications', + afterCommit: true, }, build: { - email() { + email(data: InvoicePaidNotification) { return { - subject: 'Invoice Paid', - lines: ['Your invoice has been successfully paid.'] + subject: `Invoice #${data.invoiceNumber} paid`, + lines: ['Your invoice has been successfully paid.'], } }, - database() { + database(data: InvoicePaidNotification) { return { - status: 'paid', - paidAt: new Date().toISOString() + data: { + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + }, } }, - broadcast() { + broadcast(data: InvoicePaidNotification) { return { event: 'invoice.paid', data: { - status: 'paid' - } + invoiceId: data.invoiceId, + invoiceNumber: data.invoiceNumber, + }, } - } - } + }, + }, }) ``` @@ -105,4 +113,4 @@ const invoicePaid = defineNotification({ - Reconstructing the notification - Sending it through the appropriate channel 4. If `.afterCommit()` is used, notifications are only queued after database transactions commit -5. Channel failures are isolated - if one channel fails, others continue to process \ No newline at end of file +5. Channel failures are isolated - if one channel fails, others continue to process diff --git a/apps/docs/docs/notifications/setup-and-cli.md b/apps/docs/docs/notifications/setup-and-cli.md index 78ef6c7..471ba65 100644 --- a/apps/docs/docs/notifications/setup-and-cli.md +++ b/apps/docs/docs/notifications/setup-and-cli.md @@ -73,6 +73,16 @@ notifications automatically: Auth still owns token creation and validation. Notifications only own delivery. +When auth and notifications are scaffolded together, Holo publishes editable auth notification files under +`server/notifications/auth`. Existing auth applications can publish them later: + +```bash +npx holo auth:notifications:publish +``` + +Those files define message content only. Email delivery still needs `@holo-js/mail` or another configured notification +mailer. + ## Continue - [Notifications Overview](/notifications/) diff --git a/packages/adapter-nuxt/src/runtime/composables/forms.d.ts b/packages/adapter-nuxt/src/runtime/composables/forms.d.ts index 77f9dbe..1606433 100644 --- a/packages/adapter-nuxt/src/runtime/composables/forms.d.ts +++ b/packages/adapter-nuxt/src/runtime/composables/forms.d.ts @@ -1,11 +1,22 @@ +import type { FormSchema, InferFormData } from '@holo-js/forms' +import type { + InferFormFieldTree, + UseFormOptions, + UseFormResult, +} from '@holo-js/forms/internal/client' + export type { ClientSubmitContext, ClientSubmitResult, FormFieldState, FormFieldTree, + InferFormFieldTree, UseFormOptions, UseFormResult, ValidateOnMode, } from '@holo-js/forms/internal/client' -export declare const useForm: typeof import('@holo-js/forms/internal/client').createFormClient +export declare function useForm( + schemaDefinition: TSchema, + options?: UseFormOptions, TSuccess>, +): UseFormResult, TSuccess, InferFormFieldTree> diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index bd8fafc..6a898a0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -703,6 +703,39 @@ export function createInternalCommands( if (result.createdJobsDirectory) writeLine(context.stdout, ' - created server/jobs') }, }, + { + name: 'auth:notifications:publish', + description: 'Publish editable auth notification definitions into the application.', + usage: 'holo auth:notifications:publish', + source: 'internal', + async prepare() { + return { args: [], flags: {} } + }, + async run() { + const { publishAuthNotificationsIntoProject } = await loadProjectScaffoldModule() + const result = await publishAuthNotificationsIntoProject(context.projectRoot) + const changed = result.createdFiles.length > 0 + + writeLine(context.stdout, changed + ? 'Published auth notification files.' + : 'Auth notification files are already published.') + + for (const filePath of result.createdFiles) { + writeLine(context.stdout, ` - created ${filePath}`) + } + + for (const filePath of result.skippedFiles) { + writeLine(context.stdout, ` - skipped existing ${filePath}`) + } + + if (!result.hasMailDependency) { + writeLine( + context.stdout, + ' - note: install @holo-js/mail or configure a notification mailer before email delivery can send.', + ) + } + }, + }, { name: 'prepare', description: 'Discover Holo resources and refresh generated registries.', @@ -1597,6 +1630,7 @@ export async function runCli(argv: readonly string[], io: IoStreams): Promise resolve(migrationsRoot, entry)) } +const AUTH_NOTIFICATION_FILES = Object.freeze([ + Object.freeze({ + path: 'server/notifications/auth/email-verification.ts', + render: renderAuthEmailVerificationNotification, + }), + Object.freeze({ + path: 'server/notifications/auth/password-reset.ts', + render: renderAuthPasswordResetNotification, + }), +]) + +async function writeAuthNotificationFiles(projectRoot: string): Promise<{ + readonly createdFiles: readonly string[] + readonly skippedFiles: readonly string[] +}> { + const createdFiles: string[] = [] + const skippedFiles: string[] = [] + + await mkdir(resolve(projectRoot, 'server/notifications/auth'), { recursive: true }) + + for (const file of AUTH_NOTIFICATION_FILES) { + const filePath = resolve(projectRoot, file.path) + if (await pathExists(filePath)) { + skippedFiles.push(filePath) + continue + } + + await writeTextFile(filePath, file.render()) + createdFiles.push(filePath) + } + + return { + createdFiles, + skippedFiles, + } +} + export async function installAuthIntoProject( projectRoot: string, features: AuthInstallFeatures = {}, @@ -442,6 +482,34 @@ export async function installNotificationsIntoProject( } } +export async function publishAuthNotificationsIntoProject( + projectRoot: string, +): Promise { + await loadProjectConfig(projectRoot, { required: true }) + const dependencies = await readPackageJsonDependencyState(projectRoot) + + if (!dependencies.dependencies['@holo-js/auth'] && !dependencies.devDependencies['@holo-js/auth']) { + throw new Error( + 'Auth notification publishing requires @holo-js/auth. ' + + 'Install auth first with `holo install auth`.', + ) + } + + if (!dependencies.dependencies['@holo-js/notifications'] && !dependencies.devDependencies['@holo-js/notifications']) { + throw new Error( + 'Auth notification publishing requires @holo-js/notifications. ' + + 'Install notifications first with `holo install notifications`.', + ) + } + + const result = await writeAuthNotificationFiles(projectRoot) + + return { + ...result, + hasMailDependency: !!dependencies.dependencies['@holo-js/mail'] || !!dependencies.devDependencies['@holo-js/mail'], + } +} + export async function installMailIntoProject( projectRoot: string, ): Promise { diff --git a/packages/cli/src/project/scaffold/framework.ts b/packages/cli/src/project/scaffold/framework.ts index c00d7de..2e83e35 100644 --- a/packages/cli/src/project/scaffold/framework.ts +++ b/packages/cli/src/project/scaffold/framework.ts @@ -42,6 +42,8 @@ import { createAuthMigrationFiles, createNotificationsMigrationFiles, normalizeScaffoldEnvSegments, + renderAuthEmailVerificationNotification, + renderAuthPasswordResetNotification, renderAuthUserModel, renderAuthorizationAbilitiesReadme, renderAuthorizationPoliciesReadme, @@ -322,6 +324,19 @@ export async function scaffoldProject( await writeFile(resolve(projectRoot, config.paths.migrations, migrationFile.path), migrationFile.contents, 'utf8') } } + if (authEnabled && notificationsEnabled) { + await mkdir(resolve(projectRoot, 'server/notifications/auth'), { recursive: true }) + await writeFile( + resolve(projectRoot, 'server/notifications/auth/email-verification.ts'), + renderAuthEmailVerificationNotification(), + 'utf8', + ) + await writeFile( + resolve(projectRoot, 'server/notifications/auth/password-reset.ts'), + renderAuthPasswordResetNotification(), + 'utf8', + ) + } if (broadcastEnabled && authEnabled) { await syncBroadcastAuthSupportAfterAuthInstall(projectRoot) } diff --git a/packages/cli/src/project/scaffold/project-renderers.ts b/packages/cli/src/project/scaffold/project-renderers.ts index a28c484..f982201 100644 --- a/packages/cli/src/project/scaffold/project-renderers.ts +++ b/packages/cli/src/project/scaffold/project-renderers.ts @@ -91,6 +91,78 @@ export function renderAuthUserModel(_generatedSchemaImportPath = '../../.holo-js ].join('\n') } +export function renderAuthEmailVerificationNotification(): string { + return [ + 'import { defineNotification } from \'@holo-js/notifications\'', + '', + 'interface EmailVerificationNotification {', + ' readonly email: string', + ' readonly name?: string', + ' readonly url: string', + ' readonly expiresAt: Date', + '}', + '', + 'export default defineNotification({', + ' type: \'auth.email-verification\',', + ' via() {', + ' return [\'email\']', + ' },', + ' build: {', + ' email(data: EmailVerificationNotification) {', + ' return {', + ' subject: \'Verify your email address\',', + ' greeting: data.name ? `Hello ${data.name},` : undefined,', + ' lines: [', + ' \'Confirm your account to finish signing in.\',', + ' `This verification link expires at ${data.expiresAt.toUTCString()}.`,', + ' ],', + ' action: {', + ' label: \'Verify email address\',', + ' url: data.url,', + ' },', + ' }', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export function renderAuthPasswordResetNotification(): string { + return [ + 'import { defineNotification } from \'@holo-js/notifications\'', + '', + 'interface PasswordResetNotification {', + ' readonly email: string', + ' readonly url: string', + ' readonly expiresAt: Date', + '}', + '', + 'export default defineNotification({', + ' type: \'auth.password-reset\',', + ' via() {', + ' return [\'email\']', + ' },', + ' build: {', + ' email(data: PasswordResetNotification) {', + ' return {', + ' subject: \'Reset your password\',', + ' lines: [', + ' \'Click the link below to choose a new password.\',', + ' `This reset link expires at ${data.expiresAt.toUTCString()}.`,', + ' ],', + ' action: {', + ' label: \'Reset password\',', + ' url: data.url,', + ' },', + ' }', + ' },', + ' },', + '})', + '', + ].join('\n') +} + export function renderAuthorizationPoliciesReadme(): string { return [ '# Authorization Policies', diff --git a/packages/cli/src/project/shared.ts b/packages/cli/src/project/shared.ts index 014e54f..e7e8303 100644 --- a/packages/cli/src/project/shared.ts +++ b/packages/cli/src/project/shared.ts @@ -255,6 +255,12 @@ export type NotificationsInstallResult = { readonly createdMigrationFiles: readonly string[] } +export type AuthNotificationsPublishResult = { + readonly createdFiles: readonly string[] + readonly skippedFiles: readonly string[] + readonly hasMailDependency: boolean +} + export type MailInstallResult = { readonly updatedPackageJson: boolean readonly createdMailConfig: boolean diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 2cdffbf..49b85b1 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -825,8 +825,28 @@ export default { expect(await readFile(join(authRoot, 'app/api/auth/user/route.ts'), 'utf8')).toContain('await user()') await expect(stat(join(authRoot, 'app/api/logout/route.ts'))).rejects.toThrow() await expect(stat(join(authRoot, 'proxy.ts'))).rejects.toThrow() + await expect(stat(join(authRoot, 'server/notifications/auth/email-verification.ts'))).rejects.toThrow() expect((await readdir(join(authRoot, 'server/db/migrations'))).filter(entry => entry.endsWith('.ts'))).toHaveLength(6) + const authNotificationsRoot = join(baseRoot, 'auth-notifications-runtime-app') + await projectInternals.scaffoldProject(authNotificationsRoot, { + projectName: 'Auth Notifications Runtime App', + framework: 'next', + databaseDriver: 'sqlite', + packageManager: 'bun', + storageDefaultDisk: 'local', + optionalPackages: ['auth', 'notifications'], + }) + + expect(await readFile(join(authNotificationsRoot, 'server/notifications/auth/email-verification.ts'), 'utf8')) + .toContain('export default defineNotification') + expect(await readFile(join(authNotificationsRoot, 'server/notifications/auth/password-reset.ts'), 'utf8')) + .toContain('export default defineNotification') + expect(await readFile(join(authNotificationsRoot, 'server/notifications/auth/email-verification.ts'), 'utf8')) + .toContain('auth.email-verification') + expect(await readFile(join(authNotificationsRoot, 'server/notifications/auth/password-reset.ts'), 'utf8')) + .toContain('auth.password-reset') + const authorizationRoot = join(baseRoot, 'authorization-runtime-app') await projectInternals.scaffoldProject(authorizationRoot, { projectName: 'Authorization Runtime App', @@ -7906,7 +7926,7 @@ export default defineEvent({ name: 'audit.activity' }) expect(factoryCommandIo.read().stdout).toContain('Created factory: server/db/factories/CourseFactory.ts') }, 30000) - it('lazy-loads project, dev, runtime, queue, cache migration, queue migration, and generator modules when executors are not injected', async () => { + it('lazy-loads project, dev, runtime, queue, cache migration, queue migration, and generator modules when executors are not injected', { timeout: 30000 }, async () => { const projectRoot = await createTempProject() tempDirs.push(projectRoot) const io = createIo(projectRoot) @@ -8314,7 +8334,7 @@ export default defineEvent({ name: 'audit.activity' }) projectName: 'LazyProject', framework: 'nuxt', databaseDriver: 'sqlite', - packageManager: 'bun', + packageManager: 'npm', storageDefaultDisk: 'local', optionalPackages: [], }) @@ -8332,7 +8352,7 @@ export default defineEvent({ name: 'audit.activity' }) vi.doUnmock('../src/project/discovery') vi.resetModules() } - }, 10000) + }) it('prints auth install output when only env files changed', async () => { const projectRoot = await createTempProject() @@ -8566,6 +8586,127 @@ export default defineEvent({ name: 'audit.activity' }) } }) + it('publishes editable auth notification files without overwriting existing files', async () => { + const projectRoot = await createTempProject() + tempDirs.push(projectRoot) + await writeFile(join(projectRoot, 'package.json'), JSON.stringify({ + name: 'auth-notification-fixture', + private: true, + dependencies: { + '@holo-js/auth': expectedHoloPackageRange, + '@holo-js/notifications': expectedHoloPackageRange, + }, + }, null, 2)) + + const first = await projectInternals.publishAuthNotificationsIntoProject(projectRoot) + const emailVerificationPath = join(projectRoot, 'server/notifications/auth/email-verification.ts') + const passwordResetPath = join(projectRoot, 'server/notifications/auth/password-reset.ts') + expect(first.createdFiles).toEqual([ + emailVerificationPath, + passwordResetPath, + ]) + expect(first.skippedFiles).toEqual([]) + expect(first.hasMailDependency).toBe(false) + await expect(readFile(emailVerificationPath, 'utf8')) + .resolves.toContain('interface EmailVerificationNotification') + await expect(readFile(passwordResetPath, 'utf8')) + .resolves.toContain('interface PasswordResetNotification') + await expect(readFile(emailVerificationPath, 'utf8')) + .resolves.toContain('auth.email-verification') + await expect(readFile(passwordResetPath, 'utf8')) + .resolves.toContain('auth.password-reset') + + const second = await projectInternals.publishAuthNotificationsIntoProject(projectRoot) + expect(second.createdFiles).toEqual([]) + expect(second.skippedFiles).toEqual([ + emailVerificationPath, + passwordResetPath, + ]) + }, 30000) + + it('prints auth notification publish command output', async () => { + const projectRoot = await createTempProject() + tempDirs.push(projectRoot) + const io = createIo(projectRoot) + const publishAuthNotificationsIntoProject = vi.fn(async () => ({ + createdFiles: [join(projectRoot, 'server/notifications/auth/email-verification.ts')], + skippedFiles: [join(projectRoot, 'server/notifications/auth/password-reset.ts')], + hasMailDependency: false, + })) + const findProjectRoot = vi.fn(async () => projectRoot) + const loadProjectConfig = vi.fn(async () => ({ config: defaultProjectConfig() })) + const discoverAppCommands = vi.fn(async () => { + throw new Error('auth:notifications:publish should not discover app commands') + }) + + vi.resetModules() + vi.doMock('../src/project/scaffold', async () => { + const actual = await vi.importActual('../src/project/scaffold') as typeof ProjectScaffoldInternalModule + return { + ...actual, + publishAuthNotificationsIntoProject, + } + }) + vi.doMock('../src/project/runtime', async () => { + const actual = await vi.importActual('../src/project/runtime') as typeof ProjectRuntimeInternalModule + return { + ...actual, + findProjectRoot, + } + }) + vi.doMock('../src/project/config', async () => { + const actual = await vi.importActual('../src/project/config') as typeof ProjectConfigInternalModule + return { + ...actual, + loadProjectConfig, + } + }) + vi.doMock('../src/project/discovery', async () => { + const actual = await vi.importActual('../src/project/discovery') as typeof ProjectDiscoveryInternalModule + return { + ...actual, + discoverAppCommands, + } + }) + + try { + const isolatedCli = await import('../src/cli') + const publishCommand = isolatedCli.createInternalCommands({ + ...io.io, + projectRoot, + registry: [] as Array>, + loadProject: async () => ({ config: defaultProjectConfig() }), + } as never).find(command => command.name === 'auth:notifications:publish') + + await publishCommand?.run({ + ...io.io, + projectRoot, + cwd: projectRoot, + args: [], + flags: {}, + loadProject: async () => ({ config: defaultProjectConfig() }), + } as never) + + const output = io.read().stdout + expect(output).toContain('Published auth notification files.') + expect(output).toContain('server/notifications/auth/email-verification.ts') + expect(output).toContain('skipped existing') + expect(output).toContain('install @holo-js/mail') + + const cliIo = createIo(projectRoot) + await expect(isolatedCli.runCli(['auth:notifications:publish'], cliIo.io)).resolves.toBe(0) + expect(findProjectRoot).toHaveBeenCalledWith(projectRoot) + expect(loadProjectConfig).not.toHaveBeenCalled() + expect(discoverAppCommands).not.toHaveBeenCalled() + } finally { + vi.resetModules() + vi.doUnmock('../src/project/scaffold') + vi.doUnmock('../src/project/runtime') + vi.doUnmock('../src/project/config') + vi.doUnmock('../src/project/discovery') + } + }, 30000) + it('prints full broadcast install output when scaffold reports all created artifacts', async () => { const projectRoot = await createTempProject() tempDirs.push(projectRoot) diff --git a/packages/core/src/portable/holo.ts b/packages/core/src/portable/holo.ts index a008309..f6d0ade 100644 --- a/packages/core/src/portable/holo.ts +++ b/packages/core/src/portable/holo.ts @@ -558,6 +558,26 @@ type NotificationsModule = { resetNotificationsRuntime(): void } +type AuthEmailVerificationNotification = { + readonly email: string + readonly name?: string + readonly url: string + readonly expiresAt: Date +} + +type AuthPasswordResetNotification = { + readonly email: string + readonly url: string + readonly expiresAt: Date +} + +type AuthNotificationModule = { + readonly default?: unknown + readonly notification?: unknown + readonly emailVerificationNotification?: unknown + readonly passwordResetNotification?: unknown +} + type BroadcastModule = { configureBroadcastRuntime(options?: { readonly config: LoadedHoloConfig['broadcast'] @@ -1809,9 +1829,84 @@ function formatAuthEmailExpiration(expiresAt: Date): string { return `${authEmailDateFormatter.format(expiresAt)} UTC` } +const AUTH_EMAIL_VERIFICATION_NOTIFICATION_PATHS = [ + 'server/notifications/auth/email-verification.ts', + 'server/notifications/auth/email-verification.mts', + 'server/notifications/auth/email-verification.js', + 'server/notifications/auth/email-verification.mjs', + 'server/notifications/auth/email-verification.cts', + 'server/notifications/auth/email-verification.cjs', +] as const + +const AUTH_PASSWORD_RESET_NOTIFICATION_PATHS = [ + 'server/notifications/auth/password-reset.ts', + 'server/notifications/auth/password-reset.mts', + 'server/notifications/auth/password-reset.js', + 'server/notifications/auth/password-reset.mjs', + 'server/notifications/auth/password-reset.cts', + 'server/notifications/auth/password-reset.cjs', +] as const + +function resolveExistingProjectFile(projectRoot: string | undefined, candidates: readonly string[]): string | undefined { + if (!projectRoot) { + return undefined + } + + return candidates.find(candidate => existsSync(resolve(projectRoot, candidate))) +} + +function resolveAuthNotification( + module: AuthNotificationModule, + exportName: 'emailVerificationNotification' | 'passwordResetNotification', + filePath: string, +): unknown { + const notification = module[exportName] ?? module.notification ?? module.default + if (!isAuthNotificationDefinition(notification)) { + throw new Error( + `[@holo-js/core] Auth notification file "${filePath}" must export a notification definition.`, + ) + } + + return notification +} + +function isAuthNotificationDefinition(notification: unknown): notification is { + readonly via: (...args: readonly unknown[]) => readonly string[] + readonly build: Readonly unknown>> +} { + if (!notification || typeof notification !== 'object') { + return false + } + + const candidate = notification as { + readonly via?: unknown + readonly build?: unknown + } + if (typeof candidate.via !== 'function' || !candidate.build || typeof candidate.build !== 'object') { + return false + } + + return Object.values(candidate.build).some(factory => typeof factory === 'function') +} + +async function loadProjectAuthNotification( + projectRoot: string | undefined, + candidates: readonly string[], + exportName: 'emailVerificationNotification' | 'passwordResetNotification', +): Promise { + const filePath = resolveExistingProjectFile(projectRoot, candidates) + if (!filePath) { + return undefined + } + + const module = await importRuntimeModule(projectRoot!, filePath) as AuthNotificationModule + return resolveAuthNotification(module, exportName, filePath) +} + function createAuthNotificationsDeliveryHook( notificationsModule: NotificationsModule, appUrl: string, + projectRoot?: string, ): { sendEmailVerification(input: { readonly provider: string @@ -1841,18 +1936,30 @@ function createAuthNotificationsDeliveryHook( const recipientName = typeof (input.user as { name?: unknown })?.name === 'string' ? (input.user as { name?: string }).name?.trim() : undefined + const actionUrl = createAuthActionUrl(appUrl, input.route, input.token.plainTextToken) + const authNotification: AuthEmailVerificationNotification = Object.freeze({ + email: input.email, + ...(recipientName ? { name: recipientName } : {}), + url: actionUrl, + expiresAt: input.token.expiresAt, + }) + const projectNotification = await loadProjectAuthNotification( + projectRoot, + AUTH_EMAIL_VERIFICATION_NOTIFICATION_PATHS, + 'emailVerificationNotification', + ) const lines = [ 'Confirm your account to finish signing in.', `This verification link expires at ${formatAuthEmailExpiration(input.token.expiresAt)}.`, ] as const const action = { label: 'Verify email address', - url: createAuthActionUrl(appUrl, input.route, input.token.plainTextToken), + url: actionUrl, } as const - const notification = notificationsModule.defineNotification({ + const notification = projectNotification ?? notificationsModule.defineNotification({ type: 'auth.email-verification', via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1877,28 +1984,32 @@ function createAuthNotificationsDeliveryHook( }) await notificationsModule - .notifyUsing() - .channel('email', recipientName - ? { - email: input.email, - name: recipientName, - } - : input.email) - .notify(notification) + .notify(authNotification, notification) }, async sendPasswordReset(input): Promise { + const actionUrl = createAuthActionUrl(appUrl, input.route, input.token.plainTextToken) + const authNotification: AuthPasswordResetNotification = Object.freeze({ + email: input.email, + url: actionUrl, + expiresAt: input.token.expiresAt, + }) + const projectNotification = await loadProjectAuthNotification( + projectRoot, + AUTH_PASSWORD_RESET_NOTIFICATION_PATHS, + 'passwordResetNotification', + ) const lines = [ 'Click the link below to choose a new password.', `This reset link expires at ${formatAuthEmailExpiration(input.token.expiresAt)}.`, ] as const const action = { label: 'Reset password', - url: createAuthActionUrl(appUrl, input.route, input.token.plainTextToken), + url: actionUrl, } as const - const notification = notificationsModule.defineNotification({ + const notification = projectNotification ?? notificationsModule.defineNotification({ type: 'auth.password-reset', via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1921,9 +2032,7 @@ function createAuthNotificationsDeliveryHook( }) await notificationsModule - .notifyUsing() - .channel('email', input.email) - .notify(notification) + .notify(authNotification, notification) }, }) } @@ -2131,6 +2240,53 @@ function createNotificationMailText(message: { return parts.length > 0 ? parts.join('\n\n') : undefined } +function createNotificationMailHtml(message: { + readonly subject: string + readonly greeting?: string + readonly lines?: readonly string[] + readonly action?: { + readonly label: string + readonly url: string + } +}): string { + const greeting = typeof message.greeting === 'string' + ? message.greeting.trim() + : undefined + const lines = (message.lines ?? []) + .map(line => line.trim()) + .filter(Boolean) + const sections = [ + greeting + ? `

${escapeAuthEmailHtml(greeting)}

` + : '', + ...lines.map(line => `

${escapeAuthEmailHtml(line)}

`), + message.action + ? `

` + + `` + + `${escapeAuthEmailHtml(message.action.label)}` + + `

` + : '', + message.action + ? `

` + + `If the button does not work, open this link: ` + + `${escapeAuthEmailHtml(message.action.url)}` + + `

` + : '', + ].join('') + + return [ + '', + '', + `${escapeAuthEmailHtml(message.subject)}`, + '', + '
', + `

${escapeAuthEmailHtml(message.subject)}

`, + sections, + '
', + ].join('') +} + function joinAppUrl(baseUrl: string, path: string): string { const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) @@ -2170,32 +2326,7 @@ function createAuthEmailHtml(message: { readonly url: string } }): string { - const sections = [ - typeof message.greeting === 'string' - ? `

${escapeAuthEmailHtml(message.greeting)}

` - : '', - ...message.lines.map(line => `

${escapeAuthEmailHtml(line)}

`), - `

` + - `` + - `${escapeAuthEmailHtml(message.action.label)}` + - `

`, - `

` + - `If the button does not work, open this link: ` + - `${escapeAuthEmailHtml(message.action.url)}` + - `

`, - ].join('') - - return [ - '', - '', - `${escapeAuthEmailHtml(message.subject)}`, - '', - '
', - `

${escapeAuthEmailHtml(message.subject)}

`, - sections, - '
', - ].join('') + return createNotificationMailHtml(message) } function createCoreNotificationMailSender( @@ -2224,16 +2355,12 @@ function createCoreNotificationMailSender( } const fallbackText = createNotificationMailText(message) + const fallbackHtml = createNotificationMailHtml(message) await mailModule.sendMail({ to: route, subject: message.subject, - ...(typeof message.html === 'string' ? { html: message.html } : {}), - ...(typeof (message.text ?? fallbackText) === 'string' - ? { text: (message.text ?? fallbackText)! } - : {}), - ...(typeof message.html !== 'string' && typeof (message.text ?? fallbackText) === 'string' - ? { text: (message.text ?? fallbackText)! } - : {}), + html: typeof message.html === 'string' ? message.html : fallbackHtml, + ...(typeof (message.text ?? fallbackText) === 'string' ? { text: (message.text ?? fallbackText)! } : {}), ...(message.metadata ? { metadata: message.metadata } : {}), }) }, @@ -3945,7 +4072,7 @@ export async function reconfigureOptionalHoloSubsystems { name: 'Ava', }, subject: 'Fallback message', + html: expect.stringContaining('

One line

'), text: 'One line', }) @@ -5505,33 +5506,17 @@ describe('@holo-js/core helper coverage', () => { expect((authMailSends[2] as { text: string }).text).toContain('This reset link expires at April 12, 2026 at 1:00 PM UTC.') const notificationDeliveries: unknown[] = [] - let emailVerificationRoute: unknown - let passwordResetRoute: unknown const authNotificationsHook = holoRuntimeInternals.createAuthNotificationsDeliveryHook({ defineNotification(definition: unknown) { return definition }, - notifyUsing() { - return { - channel(_channel: string, route: unknown) { - if (typeof emailVerificationRoute === 'undefined') { - emailVerificationRoute = route - } else { - passwordResetRoute = route - } - return this - }, - async notify(notification: { build: { email: (context: { name: string }) => unknown } }) { - notificationDeliveries.push({ - route: typeof passwordResetRoute === 'undefined' - ? emailVerificationRoute - : passwordResetRoute, - message: notification.build.email({ - name: ' Ava ', - }), - }) - }, - } + async notify(notifiable: { email: string, name?: string }, notification: { build: { email: (notifiable: { email: string, name?: string }) => unknown } }) { + notificationDeliveries.push({ + route: typeof notifiable.name === 'string' + ? { email: notifiable.email, name: notifiable.name } + : { email: notifiable.email }, + message: notification.build.email(notifiable), + }) }, } as never, 'https://app.test') @@ -5578,14 +5563,18 @@ describe('@holo-js/core helper coverage', () => { 'This verification link expires at April 12, 2026 at 12:00 PM UTC.', ) expect(notificationDeliveries[1]).toMatchObject({ - route: 'no-name@example.com', + route: { + email: 'no-name@example.com', + }, message: { subject: 'Verify your email address', }, }) expect((notificationDeliveries[1] as { message: { greeting?: string } }).message.greeting).toBeUndefined() expect(notificationDeliveries[2]).toMatchObject({ - route: 'reset@example.com', + route: { + email: 'reset@example.com', + }, message: { subject: 'Reset your password', action: { @@ -5599,6 +5588,147 @@ describe('@holo-js/core helper coverage', () => { ) }) + it('uses project auth notification definitions when they are published', async () => { + const root = await createProject() + await mkdir(join(root, 'server/notifications/auth'), { recursive: true }) + await writeFile(join(root, 'server/notifications/auth/email-verification.ts'), ` +import { defineNotification } from '@holo-js/notifications' + +export default defineNotification({ + type: 'auth.email-verification.custom', + via() { + return ['email'] + }, + build: { + email(data: { url: string }) { + return { + subject: 'Custom verification', + action: { + label: 'Custom verify', + url: data.url, + }, + } + }, + }, +}) +`) + await writeFile(join(root, 'server/notifications/auth/password-reset.ts'), ` +import { defineNotification } from '@holo-js/notifications' + +export default defineNotification({ + type: 'auth.password-reset.custom', + via() { + return ['email'] + }, + build: { + email(data: { url: string }) { + return { + subject: 'Custom reset', + action: { + label: 'Custom reset', + url: data.url, + }, + } + }, + }, +}) +`) + + const delivered: unknown[] = [] + const customVerificationToken = { + id: 'verify-token', + plainTextToken: 'verify-plain', + expiresAt: new Date('2026-04-12T12:00:00.000Z'), + } + const hook = holoRuntimeInternals.createAuthNotificationsDeliveryHook({ + defineNotification(definition: unknown) { + return definition + }, + async notify(notifiable: unknown, notification: { build: { email: (notifiable: unknown) => unknown }, type?: string }) { + delivered.push({ + type: notification.type, + message: notification.build.email(notifiable), + }) + }, + } as never, 'https://app.test', root) + + await hook.sendEmailVerification({ + provider: 'users', + user: { name: 'Ava' }, + email: 'ava@example.com', + token: customVerificationToken, + route: '/verify-email', + }) + await hook.sendPasswordReset({ + broker: 'users', + provider: 'users', + email: 'reset@example.com', + token: { + id: 'reset-token', + plainTextToken: 'reset-plain', + expiresAt: new Date('2026-04-12T13:00:00.000Z'), + }, + route: '/reset-password', + }) + + expect(delivered).toEqual([ + { + type: 'auth.email-verification.custom', + message: { + subject: 'Custom verification', + action: { + label: 'Custom verify', + url: 'https://app.test/verify-email?token=verify-plain', + }, + }, + }, + { + type: 'auth.password-reset.custom', + message: { + subject: 'Custom reset', + action: { + label: 'Custom reset', + url: 'https://app.test/reset-password?token=reset-plain', + }, + }, + }, + ]) + }) + + it('fails clearly when a published auth notification file is malformed', async () => { + const root = await createProject() + await mkdir(join(root, 'server/notifications/auth'), { recursive: true }) + await writeFile(join(root, 'server/notifications/auth/email-verification.ts'), ` +export default { + via() { + return ['email'] + }, + build: { + email: null, + }, +} +`) + + const hook = holoRuntimeInternals.createAuthNotificationsDeliveryHook({ + defineNotification(definition: unknown) { + return definition + }, + async notify() {}, + } as never, 'https://app.test', root) + + await expect(hook.sendEmailVerification({ + provider: 'users', + user: { name: 'Ava' }, + email: 'ava@example.com', + token: { + id: 'verify-token', + plainTextToken: 'verify-plain', + expiresAt: new Date('2026-04-12T12:00:00.000Z'), + }, + route: '/verify-email', + })).rejects.toThrow('must export a notification definition') + }) + it('boots mail with the shared render runtime when no explicit render option is passed', async () => { const root = await createProject() await writeBaseConfig(root) diff --git a/packages/notifications/src/contracts.ts b/packages/notifications/src/contracts.ts index 574b267..eb4cc1b 100644 --- a/packages/notifications/src/contracts.ts +++ b/packages/notifications/src/contracts.ts @@ -200,6 +200,16 @@ export interface NotificationDefinition< | NotificationDelayResolver } +type NotificationDefinitionInput< + TNotifiable, + TBuild extends NotificationBuildFactories, +> = Omit, 'via'> & { + via( + notifiable: TNotifiable, + context: NotificationContext, + ): readonly Extract[] +} + export type InferNotificationNotifiable = TNotification extends NotificationDefinition> ? TNotifiable @@ -372,9 +382,17 @@ function normalizeQueueOptions( }) } -function normalizeDelayConfig( - value: NotificationDefinition>['delay'] | undefined, -): NotificationDefinition>['delay'] | undefined { +function normalizeDelayConfig( + value: + | NotificationDelayValue + | Partial> + | NotificationDelayResolver + | undefined, +): + | NotificationDelayValue + | Partial> + | NotificationDelayResolver + | undefined { if (typeof value === 'undefined' || typeof value === 'function') { return value } @@ -395,9 +413,9 @@ function normalizeDelayConfig( export function normalizeNotificationDefinition< TNotifiable, - TBuild extends NotificationBuildFactories, + const TBuild extends NotificationBuildFactories, >( - definition: NotificationDefinition, + definition: NotificationDefinitionInput, ): NotificationDefinition { if (!isNotificationDefinition(definition)) { throw new Error('[@holo-js/notifications] Notifications must define via() and build.') @@ -423,15 +441,15 @@ export function normalizeNotificationDefinition< ? definition.queue : normalizeQueueOptions(definition.queue) - const normalized = { + const normalized: NotificationDefinition = { ...definition, ...(typeof definition.type === 'undefined' ? {} : { type: normalizeOptionalString(definition.type, 'Notification type') }), build, queue, - delay: normalizeDelayConfig(definition.delay), - } as NotificationDefinition + delay: normalizeDelayConfig>(definition.delay), + } Object.defineProperty(normalized, HOLO_NOTIFICATION_DEFINITION_MARKER, { value: true, @@ -443,9 +461,9 @@ export function normalizeNotificationDefinition< export function defineNotification< TNotifiable, - TBuild extends NotificationBuildFactories, + const TBuild extends NotificationBuildFactories, >( - definition: NotificationDefinition, + definition: NotificationDefinitionInput, ): NotificationDefinition { return normalizeNotificationDefinition(definition) } diff --git a/packages/notifications/src/runtime.ts b/packages/notifications/src/runtime.ts index 020990a..5abc4a0 100644 --- a/packages/notifications/src/runtime.ts +++ b/packages/notifications/src/runtime.ts @@ -801,10 +801,19 @@ export function resetNotificationsRuntime(): void { state.loadDbModule = undefined } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- notify must accept any NotificationDefinition variant without TBuild variance issues -export function notify>( +type NotificationDefinitionLike + = TNotification extends NotificationDefinition + ? TNotification + : never + +export function notify( notifiable: InferNotificationNotifiable, - notification: TNotification, + notification: NotificationDefinitionLike, +): PendingNotificationDispatch + +export function notify( + notifiable: unknown, + notification: NotificationDefinition, ): PendingNotificationDispatch { return new PendingDispatch({ kind: 'notifiable', @@ -812,10 +821,14 @@ export function notify>( }, notification) } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- notifyMany must accept any NotificationDefinition variant without TBuild variance issues -export function notifyMany>( +export function notifyMany( notifiables: readonly InferNotificationNotifiable[] | Iterable>, - notification: TNotification, + notification: NotificationDefinitionLike, +): PendingNotificationDispatch + +export function notifyMany( + notifiables: readonly unknown[] | Iterable, + notification: NotificationDefinition, ): PendingNotificationDispatch { return new PendingDispatch(() => ({ kind: 'many', diff --git a/packages/notifications/tests/contracts.test.ts b/packages/notifications/tests/contracts.test.ts index 6932a90..ebbb93b 100644 --- a/packages/notifications/tests/contracts.test.ts +++ b/packages/notifications/tests/contracts.test.ts @@ -10,7 +10,7 @@ describe('@holo-js/notifications contracts', () => { const definition = defineNotification({ type: ' invoice.paid ', via() { - return ['email', 'database'] as const + return ['email', 'database'] }, build: { email() { @@ -55,16 +55,15 @@ describe('@holo-js/notifications contracts', () => { it('rejects malformed definitions, delays, and queue options', () => { expect(() => defineNotification({ - // @ts-expect-error - intentionally returning wrong channel type to test validation via() { return ['email'] }, build: {}, - })).toThrow('must define at least one channel payload builder') + } as never)).toThrow('must define at least one channel payload builder') expect(() => defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email: 'broken' as never, @@ -74,7 +73,7 @@ describe('@holo-js/notifications contracts', () => { expect(() => defineNotification({ type: ' ', via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -87,7 +86,7 @@ describe('@holo-js/notifications contracts', () => { expect(() => defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -103,7 +102,7 @@ describe('@holo-js/notifications contracts', () => { expect(() => defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -126,7 +125,7 @@ describe('@holo-js/notifications contracts', () => { const delayResolver = () => 30 const queueDefinition = notificationsInternals.normalizeNotificationDefinition({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -143,7 +142,7 @@ describe('@holo-js/notifications contracts', () => { expect(queueDefinition.delay).toBe(delayResolver) expect(defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email() { diff --git a/packages/notifications/tests/contracts.type.test.ts b/packages/notifications/tests/contracts.type.test.ts index a4864c3..a642b50 100644 --- a/packages/notifications/tests/contracts.type.test.ts +++ b/packages/notifications/tests/contracts.type.test.ts @@ -50,7 +50,7 @@ describe('@holo-js/notifications typing', () => { const userRegistered = defineNotification({ type: 'user.registered', via(user: { id: string, email: string }) { - return ['email', 'slack'] as const + return ['email', 'slack'] }, build: { email(user) { @@ -101,6 +101,21 @@ describe('@holo-js/notifications typing', () => { // @ts-expect-error Wrong route shape for the custom channel must fail. notifyUsing().channel('slack', 'broken') + defineNotification({ + type: 'missing-builder', + // @ts-expect-error via() cannot list channels without a matching builder. + via() { + return ['database'] + }, + build: { + email() { + return { + subject: 'Hello', + } + }, + }, + }) + void pending void routed void channelName diff --git a/packages/notifications/tests/index.type.test.ts b/packages/notifications/tests/index.type.test.ts index 6816fdf..46c7ed3 100644 --- a/packages/notifications/tests/index.type.test.ts +++ b/packages/notifications/tests/index.type.test.ts @@ -21,7 +21,7 @@ describe('@holo-js/notifications root export typing', () => { const definition = defineNotification({ type: 'report-ready', via(user: { id: string, email: string }) { - return ['email', 'database'] as const + return ['email', 'database'] }, build: { email(user) { diff --git a/packages/notifications/tests/package.test.ts b/packages/notifications/tests/package.test.ts index dcb251d..9f6d350 100644 --- a/packages/notifications/tests/package.test.ts +++ b/packages/notifications/tests/package.test.ts @@ -21,7 +21,7 @@ describe('@holo-js/notifications package surface', () => { it('exports the package helpers and config helper', () => { const definition = defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email() { diff --git a/packages/notifications/tests/runtime.test.ts b/packages/notifications/tests/runtime.test.ts index 9feae51..823c25a 100644 --- a/packages/notifications/tests/runtime.test.ts +++ b/packages/notifications/tests/runtime.test.ts @@ -49,7 +49,7 @@ const invoicePaidDefinition: NotificationDefinition< > = { type: 'invoice-paid', via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, build: { email(user: { email: string }) { @@ -380,7 +380,7 @@ describe('@holo-js/notifications runtime', () => { .channel('email', { email: 'ava@example.com' }) .notify({ via() { - return ['email', 'database'] as const + return ['email', 'database'] }, build: { email() { @@ -476,9 +476,9 @@ describe('@holo-js/notifications runtime', () => { const missingBuilder = await notify({ email: 'ava@example.com', - }, { + } as never, { via() { - return ['email', 'database'] as const + return ['email', 'database'] }, build: { email() { @@ -487,7 +487,7 @@ describe('@holo-js/notifications runtime', () => { } }, }, - }) + } as never) expect(missingBuilder.channels).toEqual([ { @@ -510,7 +510,7 @@ describe('@holo-js/notifications runtime', () => { email: 'ava@example.com', } as never, { via() { - return ['sms'] as const + return ['sms'] }, build: { sms() { @@ -560,7 +560,7 @@ describe('@holo-js/notifications runtime', () => { > = defineNotification({ type: 'invoice-paid', via() { - return ['email', 'database', 'broadcast'] as const + return ['email', 'database', 'broadcast'] }, queue(_notifiable: InvoicePaidNotifiable, channel: string) { if (channel === 'broadcast') { @@ -635,9 +635,9 @@ describe('@holo-js/notifications runtime', () => { const result = await notify({ email: 'ava@example.com', - }, { + }, defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -646,7 +646,7 @@ describe('@holo-js/notifications runtime', () => { } }, }, - }).onQueue('notifications') + })).onQueue('notifications') expect(result.channels).toHaveLength(1) expect(result.channels[0]).toMatchObject({ @@ -689,9 +689,9 @@ describe('@holo-js/notifications runtime', () => { const result = await notify({ email: 'ava@example.com', - }, { + }, defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -703,7 +703,7 @@ describe('@holo-js/notifications runtime', () => { queue: { afterCommit: true, }, - }) + })) expect(result).toEqual({ totalTargets: 1, @@ -753,9 +753,9 @@ describe('@holo-js/notifications runtime', () => { const result = await notify({ email: 'ava@example.com', - }, { + }, defineNotification({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -764,7 +764,7 @@ describe('@holo-js/notifications runtime', () => { } }, }, - }).afterCommit() + })).afterCommit() expect(result).toEqual({ totalTargets: 1, @@ -828,7 +828,7 @@ describe('@holo-js/notifications runtime', () => { .channel('slack', { webhook: 'https://hooks.slack.test' } as never) .notify({ via() { - return ['slack'] as const + return ['slack'] }, build: { slack() { @@ -898,7 +898,7 @@ describe('@holo-js/notifications runtime', () => { }, } as never, { via() { - return ['slack'] as const + return ['slack'] }, build: { slack() { @@ -954,7 +954,7 @@ describe('@holo-js/notifications runtime', () => { .channel('slack', { webhook: 'http://hooks.slack.test' } as never) .notify({ via() { - return ['slack'] as const + return ['slack'] }, build: { slack() { @@ -978,7 +978,7 @@ describe('@holo-js/notifications runtime', () => { .channel('slack', { webhook: 'https://hooks.slack.test' } as never) .notify({ via() { - return ['slack'] as const + return ['slack'] }, build: { slack() { @@ -1024,7 +1024,7 @@ describe('@holo-js/notifications runtime', () => { }, } as never, { via() { - return ['email', 'slack'] as const + return ['email', 'slack'] }, build: { email() { @@ -1255,7 +1255,7 @@ describe('@holo-js/notifications runtime', () => { }, }, { via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1371,7 +1371,7 @@ describe('@holo-js/notifications runtime', () => { expect(notificationsRuntimeInternals.resolveNotificationQueueOptions({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1393,7 +1393,7 @@ describe('@holo-js/notifications runtime', () => { expect(notificationsRuntimeInternals.resolveNotificationDelay({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1411,7 +1411,7 @@ describe('@holo-js/notifications runtime', () => { const delayedAt = new Date('2026-01-01T00:00:00.000Z') expect(notificationsRuntimeInternals.resolveNotificationDelay({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1428,7 +1428,7 @@ describe('@holo-js/notifications runtime', () => { }, 'email')).toBe(delayedAt) expect(notificationsRuntimeInternals.resolveNotificationDelay({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1638,7 +1638,7 @@ describe('@holo-js/notifications runtime', () => { expect(notificationsRuntimeInternals.resolveChannelDispatchPlan({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1678,7 +1678,7 @@ describe('@holo-js/notifications runtime', () => { expect(notificationsRuntimeInternals.resolveNotificationDelay({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1697,7 +1697,7 @@ describe('@holo-js/notifications runtime', () => { }, 'email')).toBe(50) expect(notificationsRuntimeInternals.resolveNotificationDelay({ via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1726,7 +1726,7 @@ describe('@holo-js/notifications runtime', () => { }, notification: { via() { - return ['email'] as const + return ['email'] }, build: { email() { @@ -1743,7 +1743,7 @@ describe('@holo-js/notifications runtime', () => { notifiable: { email: 'ava@example.com' }, }], { via() { - return ['email'] as const + return ['email'] }, build: { email() {