When using the revendiste notification system or want to send notifications
A type-safe, scalable notification system with email template support using React Email.
import {NotificationService} from '~/services/notifications';
import {
notifySellerTicketSold,
notifyOrderConfirmed,
notifyIdentityVerificationCompleted,
notifyIdentityVerificationRejected,
} from '~/services/notifications/helpers';
// Simple usage with helper function
await notifySellerTicketSold(notificationService, {
sellerUserId: userId,
listingId: listing.id,
eventName: order.event.name,
eventStartDate: new Date(order.event.eventStartDate),
eventEndDate: new Date(order.event.eventEndDate),
platform: 'ticketmaster',
qrAvailabilityTiming: '12h',
ticketCount: 2,
});
// Identity verification completed (user can now sell)
await notifyIdentityVerificationCompleted(notificationService, {
userId: user.id,
});
// Identity verification rejected by admin
await notifyIdentityVerificationRejected(notificationService, {
userId: user.id,
rejectionReason: 'El documento no es legible',
canRetry: true,
});
// Or create directly (title and description are auto-generated from metadata)
await notificationService.createNotification({
userId: userId,
type: 'order_confirmed',
channels: ['in_app', 'email'],
actions: [
{
type: 'view_order',
label: 'Ver orden',
url: `${APP_BASE_URL}/cuenta/tickets?orderId=${orderId}`,
},
],
metadata: {
type: 'order_confirmed',
orderId,
eventName,
totalAmount: '100.00',
subtotalAmount: '90.00',
platformCommission: '10.00',
vatOnCommission: '0.00',
currency: 'EUR',
items: [],
},
});
Note: Title and description are automatically generated from the notification type and metadata - you don't need to provide them when creating notifications.
Some notifications benefit from batching to avoid spam. For example, when a seller uploads multiple ticket documents for the same order, we don't want to send separate emails for each upload. Instead, we batch them into a single notification.
document_uploaded:{orderId})// notifyDocumentUploaded automatically uses debouncing
await notifyDocumentUploaded(notificationService, {
buyerUserId: buyer.id,
orderId: order.id,
eventName: 'Concert',
ticketCount: 1,
});
// Or create a debounced notification directly
await notificationService.createDebouncedNotification({
userId: buyer.id,
type: 'document_uploaded',
channels: ['in_app', 'email'],
metadata: { ... },
actions: [ ... ],
debounce: {
key: `document_uploaded:${orderId}`, // Unique grouping key
windowMs: 5 * 60 * 1000, // 5 minute window
},
});
The process-pending-notifications cronjob handles both:
document_uploaded → merged into document_uploaded_batchHelper functions simplify notification creation with pre-configured channels, actions, and metadata structure:
Order & Ticket Helpers:
notifySellerTicketSold() - Notify seller when tickets soldnotifyDocumentReminder() - Remind seller to upload documentsnotifyDocumentUploaded() - Notify buyer when documents uploaded (uses debouncing)notifyDocumentUploadedImmediate() - Same as above but immediate (no debouncing)notifyOrderConfirmed() - Order confirmationnotifyOrderExpired() - Order expirationnotifyPaymentFailed() - Payment failurePayout Helpers:
notifyPayoutCompleted() - Payout completednotifyPayoutFailed() - Payout failednotifyPayoutCancelled() - Payout cancelledIdentity Verification Helpers:
notifyIdentityVerificationCompleted() - Verification successfulnotifyIdentityVerificationRejected() - Admin rejected verificationnotifyIdentityVerificationFailed() - System failure (in_app only)notifyIdentityVerificationManualReview() - Needs admin review (in_app only)The notification system uses discriminated unions and function overloading to provide full type safety:
Metadata Schemas (packages/shared/src/schemas/notifications.ts)
Email Templates (packages/transactional/)
emails/ directoryTemplate Builder (apps/backend/src/services/notifications/email-template-builder.ts)
Database Types (packages/shared/src/types/db.d.ts)
NotificationType enum is generated from databaseNotification model type is in apps/backend/src/types/models.ts as Selectable<Notifications>Order & Ticket Notifications:
ticket_sold_seller - Seller notification when tickets are solddocument_reminder - Seller reminder to upload documentsdocument_uploaded - Buyer notification when seller uploads ticket documentsorder_confirmed - Order confirmationorder_expired - Order expirationPayment Notifications:
payment_failed - Payment failurepayment_succeeded - Payment successPayout Notifications:
payout_processing - Payout started processing (legacy, maps to completed)payout_completed - Payout completed successfullypayout_failed - Payout failedpayout_cancelled - Payout cancelledIdentity Verification Notifications:
identity_verification_completed - Verification successful (auto or admin approved)identity_verification_rejected - Admin rejected verificationidentity_verification_failed - System failure (face mismatch, liveness fail)identity_verification_manual_review - Borderline scores, needs admin reviewAuth Notifications (Clerk webhook triggered):
auth_verification_code - OTP for email verificationauth_reset_password_code - OTP for password resetauth_invitation - User invitationauth_password_changed - Password changed notificationauth_password_removed - Password removed notificationauth_primary_email_changed - Primary email changed notificationauth_new_device_sign_in - New device sign-in alertin_app + email: order_confirmed, order_expired, payment_failed, document_reminder, document_uploaded, ticket_sold_seller, payout_completed, payout_failed, payout_cancelled, seller_earnings_retained, buyer_ticket_cancelled, ticket_report_created, auth types (verification code, reset password, invitation, etc.)
in_app only: identity_verification_failed, identity_verification_manual_review, ticket_report_status_changed, ticket_report_action_added, ticket_report_closed
Actions allow in-app notifications to be clickable and redirect users:
upload_documents - Redirect to upload ticket documentsview_order - Redirect to view order detailsretry_payment - Redirect to retry paymentview_payout - Redirect to view payout detailsstart_verification - Redirect to start/retry identity verificationpublish_tickets - Redirect to publish tickets pagein_app - Stored in DB, shown in the notification bell UI. Always marked "sent" on creation (no async send).email - Rendered via React Email and sent through the email provider (Resend). Requires a case in getEmailTemplate() in packages/transactional/src/email-templates.ts; otherwise the type hits the default branch and throws "Unknown notification type".sms - SMS notifications (future)Use ['in_app', 'email'] for high-value notifications:
order_confirmed)payment_failed)payout_completed)identity_verification_completed)identity_verification_rejected)document_uploaded)Use ['in_app'] only to save email costs:
identity_verification_manual_review)identity_verification_failed)// Example: in_app only (no email cost)
await service.createNotification({
userId,
type: 'identity_verification_manual_review',
channels: ['in_app'], // No email - just informational
actions: null,
metadata: {type: 'identity_verification_manual_review'},
});
// Example: high-value notification (email + in_app)
await service.createNotification({
userId,
type: 'identity_verification_completed',
channels: ['in_app', 'email'], // Email is valuable here
actions: [{type: 'publish_tickets', label: 'Publicar entradas', url: '...'}],
metadata: {type: 'identity_verification_completed'},
});
pending - Created but not yet sentsent - Successfully sent (all channels succeeded or partial success)failed - Failed to send (all channels failed, will be retried by cronjob)seen - User has seen the notification (in-app only)Each notification tracks delivery status per channel:
channelStatus (JSONB): Tracks status for each channel individually
{"email": {"status": "sent", "sentAt": "..."}, "in_app": {"status": "failed", "error": "..."}}sent if any channel succeedsretryCount (integer): Tracks number of retry attempts (max 5)Every new notification type must touch each layer. High-level order:
| Step | File | Purpose |
|---|---|---|
| 1 | packages/shared/src/schemas/notifications.ts | Metadata schema, actions schema, full notification schema; add to discriminated unions |
| 2 | packages/shared/src/utils/notification-text.ts | Add case in generateNotificationText() for title/description |
| 3 | apps/backend/src/services/notifications/helpers.ts | Helper that calls service.createNotification() (optional but recommended) |
| 4 | packages/transactional/emails/{name}-email.tsx | React Email template (skip if in_app only) |
| 5 | packages/transactional/src/email-templates.ts | Add case in getEmailTemplate() (or throw for in_app-only types) |
| 6 | packages/transactional/src/index.ts | Export template and props type |
| 7 | DB migration + pnpm generate:db | Add enum value to notification_type; regenerate types |
Below are the detailed steps.
In packages/shared/src/schemas/notifications.ts:
// 1. Add metadata schema
export const MyNewNotificationMetadataSchema = z.object({
type: z.literal('my_new_notification'),
// Add your fields here
orderId: z.uuid(),
eventName: z.string(),
customField: z.string(),
});
// 2. Add action schema (if needed)
// First, add your new action type to NotificationActionType enum if needed:
export const NotificationActionType = z.enum([
'upload_documents',
'view_order',
'retry_payment',
'view_payout',
'start_verification',
'publish_tickets',
'my_new_action', // Add your new action type here
]);
export const MyNewNotificationActionsSchema = z
.array(
BaseActionSchema.extend({
type: z.literal('my_new_action'), // Use your specific action type
label: z.string(),
url: z.url(), // Use z.url() not z.string().url()
}),
)
.nullable();
// 3. Add notification schema
export const MyNewNotificationSchema = BaseNotificationSchema.extend({
type: z.literal('my_new_notification'),
metadata: MyNewNotificationMetadataSchema,
actions: MyNewNotificationActionsSchema,
});
// 4. Add to discriminated unions
export const NotificationMetadataSchema = z.discriminatedUnion('type', [
// ... existing schemas
MyNewNotificationMetadataSchema, // Add here
]);
export const NotificationSchema = z.discriminatedUnion('type', [
// ... existing schemas
MyNewNotificationSchema, // Add here
]);
In packages/transactional/emails/my-new-notification-email.tsx:
import {Button, Section, Text} from '@react-email/components';
import {BaseEmail} from './base-template';
export interface MyNewNotificationEmailProps {
eventName: string;
orderId: string;
customField: string;
appBaseUrl?: string;
}
export const MyNewNotificationEmail = ({
eventName,
orderId,
customField,
appBaseUrl,
}: MyNewNotificationEmailProps) => (
<BaseEmail
title="My New Notification"
preview={`Notification for ${eventName}`}
appBaseUrl={appBaseUrl}
>
<Text className="text-foreground mb-4">Content here...</Text>
</BaseEmail>
);
MyNewNotificationEmail.PreviewProps = {
eventName: 'Example Event',
orderId: '123',
customField: 'example',
} as MyNewNotificationEmailProps;
export default MyNewNotificationEmail;
In packages/transactional/src/index.ts:
// Export the component
export * from '../emails/my-new-notification-email';
// Export prop types
export type {MyNewNotificationEmailProps} from '../emails/my-new-notification-email';
In packages/transactional/src/email-templates.ts:
// 1. Import the component and props
import {
MyNewNotificationEmail as MyNewNotificationEmailComponent,
type MyNewNotificationEmailProps,
} from '../emails/my-new-notification-email';
import type {NotificationType, TypedNotificationMetadata} from '@revendiste/shared';
// Note: NotificationType is now imported from @revendiste/shared (generated from database)
// 2. Add switch case in implementation
case 'my_new_notification': {
const meta = metadata as TypedNotificationMetadata<'my_new_notification'>;
return {
Component: MyNewNotificationEmailComponent,
props: {
eventName: meta?.eventName || 'el evento',
orderId: meta?.orderId || '',
customField: meta?.customField || '',
appBaseUrl,
},
};
}
Note: NotificationType is now generated from the database enum, so you don't need to manually add it to a union type. The database enum will be updated in Step 6.
Note: The email template builder now uses a unified getEmailTemplate() function from the transactional package. No changes needed here - the switch statement in packages/transactional/src/email-templates.ts handles all notification types.
Create a migration to add the new enum value using PostgreSQL's ALTER TYPE ... ADD VALUE:
// In migration file
export async function up(db: Kysely<any>): Promise<void> {
// Add new value to the enum (appends to the end by default)
await sql`
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'my_new_notification'
`.execute(db);
}
Options for enum value positioning:
-- Add at the end (default)
ALTER TYPE notification_type ADD VALUE 'my_new_notification';
-- Add before an existing value
ALTER TYPE notification_type ADD VALUE 'my_new_notification' BEFORE 'order_confirmed';
-- Add after an existing value
ALTER TYPE notification_type ADD VALUE 'my_new_notification' AFTER 'payment_succeeded';
-- Use IF NOT EXISTS to avoid errors if value already exists
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'my_new_notification';
Important notes:
ALTER TYPE ... ADD VALUE cannot be executed inside a transaction block in PostgreSQL < 12After running the migration, regenerate database types:
cd apps/backend && pnpm generate:db
This updates NotificationType in packages/shared/src/types/db.d.ts.
Note: After regenerating database types (Step 6), NotificationType will automatically include the new value. However, you may need to update the repository interface if it uses a union type instead of importing from shared:
In apps/backend/src/repositories/notifications/index.ts:
import type {NotificationType} from '~/shared';
export interface CreateNotificationData {
// ...
type: NotificationType; // Uses generated enum type
// ...
}
If the interface uses a union type, update it to include the new value, or better yet, import NotificationType from ~/shared.
In packages/shared/src/utils/notification-text.ts, add a case to generateNotificationText():
case 'my_new_notification': {
const meta = metadata as TypedNotificationMetadata<'my_new_notification'>;
return {
title: 'My New Notification',
description: `Notification for ${meta.eventName}`,
};
}
Note: Title and description are generated from metadata, not stored in the database. This function is called automatically when notifications are created or retrieved.
In apps/backend/src/services/notifications/helpers.ts:
export async function notifyMyNewNotification(
service: NotificationService,
params: {
userId: string;
orderId: string;
eventName: string;
customField: string;
},
) {
return await service.createNotification({
userId: params.userId,
type: 'my_new_notification',
channels: ['in_app', 'email'],
actions: [
{
type: 'view_order',
label: 'View Details',
url: `${APP_BASE_URL}/orders/${params.orderId}`,
},
],
metadata: {
type: 'my_new_notification' as const,
orderId: params.orderId,
eventName: params.eventName,
customField: params.customField,
},
});
}
Note: Helper functions don't need to provide title or description - they're automatically generated from the metadata.
All notification schemas are in the shared package (packages/shared/src/schemas/notifications.ts):
BaseNotificationSchema, BaseActionSchema)NotificationMetadataSchema, NotificationSchema)Benefits:
The notification system uses discriminated unions for type safety:
// Each notification type has its own metadata schema (in shared package)
export const TicketSoldSellerMetadataSchema = z.object({
type: z.literal('ticket_sold_seller'), // Discriminator
listingId: z.uuid(),
eventName: z.string(),
eventStartDate: z.string(),
ticketCount: z.number().int().positive(),
platform: z.string(),
qrAvailabilityTiming: z.custom<QrAvailabilityTiming>().nullable().optional(),
shouldPromptUpload: z.boolean(),
});
// Discriminated union ensures type safety
export const NotificationMetadataSchema = z.discriminatedUnion('type', [
TicketSoldSellerMetadataSchema,
DocumentReminderMetadataSchema,
DocumentUploadedMetadataSchema,
OrderConfirmedMetadataSchema,
// ... other schemas
]);
// TypeScript infers the correct type based on 'type' field
type Metadata = z.infer<typeof NotificationMetadataSchema>;
// Metadata is: TicketSoldSellerMetadata | DocumentReminderMetadata | ...
NotificationType is generated from the database enum in packages/shared/src/types/db.d.tsNotification model type is in apps/backend/src/types/models.ts as Selectable<Notifications>pnpm generate:db after migrations to update typesThe getEmailTemplate() function maps notification types to their email templates:
// Single function signature (no overloading needed)
export function getEmailTemplate<T extends NotificationType>(
props: EmailTemplateProps<T>,
): {
Component: React.ComponentType<any>;
props: Record<string, any>;
};
// Switch statement handles type mapping
switch (notificationType) {
case 'ticket_sold_seller': {
const meta = metadata as TypedNotificationMetadata<'ticket_sold_seller'>;
return {
Component: SellerTicketSoldEmailComponent,
props: {
eventName: meta?.eventName || 'el evento',
eventStartDate: meta?.eventStartDate || new Date().toISOString(),
ticketCount: meta?.ticketCount || 1,
uploadUrl: meta?.shouldPromptUpload ? uploadUrl : undefined,
appBaseUrl,
},
};
}
// ... other cases
}
Note: Function overloading was simplified to a single signature with a switch statement, which is sufficient for runtime type mapping.
Title and description are automatically generated from metadata - they are not stored in the database:
generateNotificationText() function in packages/shared/src/utils/notification-text.tsWhen creating a notification, the system validates data in this order:
Metadata Validation (NotificationService.createNotification)
NotificationMetadataSchema (discriminated union)type field matches notification typeValidationError if validation failsActions Validation
NotificationActionsSchema (generic array schema)null to undefined for consistencyTitle/Description Generation
generateNotificationText()Full Notification Validation
NotificationSchema (discriminated union)Database Creation
Parse Metadata (parseNotificationMetadata)
Build Template (buildEmailTemplate)
getEmailTemplate()renderEmail()Send Email (NotificationService.sendEmailNotification)
generateNotificationText())EMAIL_PROVIDER=resend # Options: console, resend
[email protected]
RESEND_API_KEY=re_your_api_key_here # Required when EMAIL_PROVIDER=resend
The system uses a factory pattern to select email providers:
Change EMAIL_PROVIDER environment variable - no code changes needed.
GET /notifications - Get user notifications (with pagination)GET /notifications/unseen-count - Get count of unseen notificationsPATCH /notifications/:id/seen - Mark notification as seenPATCH /notifications/seen-all - Mark all as seenDELETE /notifications/:id - Delete notificationCall notification helpers with .catch() so notification failures don't break the main flow:
// ✅ CORRECT - Failures in notification don't break business logic
notifyOrderConfirmed(notificationService, { ... }).catch(err =>
logger.error('Failed to send order confirmation notification', err),
);
For heavy emails (attachments, invoices), use deferSendToJob: true. The notification is created immediately; a cronjob picks it up and sends the email (e.g. with PDF attachment). Avoids blocking the request and keeps transactions short.
// Example: invoice email deferred to job (see helpers that use deferSendToJob)
await notificationService.createNotification({
userId,
type: 'order_invoice',
channels: ['email'],
deferSendToJob: true,
// attachmentRefs / postSendActions as needed
});
Title and description are automatically generated from metadata - you never provide them:
// ✅ CORRECT - Title/description auto-generated
await notificationService.createNotification({
userId: userId,
type: 'order_confirmed',
channels: ['in_app', 'email'],
metadata: {
type: 'order_confirmed',
orderId,
eventName,
// ... other fields
},
});
// Title: "Orden confirmada"
// Description: Generated from metadata
// ❌ WRONG - Don't provide title/description
await notificationService.createNotification({
userId: userId,
type: 'order_confirmed',
title: 'Custom title', // Not accepted!
description: 'Custom description', // Not accepted!
// ...
});
Notifications are sent asynchronously - your business logic doesn't wait. Prefer calling helpers without await, or with .catch() so failures don't break the main flow (see "Fire-and-Forget and Error Isolation" above).
// ✅ CORRECT - Non-blocking
await notificationService.createNotification({...});
// Your code continues immediately; notification is sent in the background
Email sending happens outside database transactions (follows Transaction Safety pattern):
// ✅ CORRECT - Email outside transaction
await this.repository.executeTransaction(async trx => {
// Database operations only
await repo.create({...});
});
// Email sent after transaction commits
await notificationService.createNotification({...});
failed and error messagechannelStatus JSONB fieldbaseDelay * 2^retryCount where baseDelay = 5 minutesretryCount column)sentsent or failed, or all channels already processed)Email templates are React components in packages/transactional/emails/:
@react-email/components for email-safe componentsBaseEmail wrapper for consistent layoutPreviewProps for React Email previewTemplates are mapped via getEmailTemplate() in packages/transactional/src/email-templates.ts:
buildEmailTemplate() with typed metadatagetEmailTemplate() (type-safe via overloading)renderEmail() in transactional packageKey Point: React stays in the transactional package - backend never imports React directly.
notifySellerTicketSold only adds upload action if within time window)['in_app'] only for informational updatesNotificationActionTypeSome notifications only use in_app channel and don't need email templates:
// identity_verification_manual_review - in_app only
await notifyIdentityVerificationManualReview(service, {userId});
// identity_verification_failed - in_app only (user can retry in UI)
await notifyIdentityVerificationFailed(service, {
userId,
failureReason: 'No pudimos verificar tu identidad',
attemptsRemaining: 3,
});
For these notifications:
getEmailTemplate() in packages/transactional/src/email-templates.ts. Otherwise the type hits the default branch and throws "Unknown notification type":case 'identity_verification_manual_review':
case 'identity_verification_failed':
case 'ticket_report_status_changed':
case 'ticket_report_action_added':
case 'ticket_report_closed':
throw new Error(
`Notification type ${notificationType} is in_app only and does not have an email template`,
);
When working on notifications, these files are the main touchpoints:
packages/shared/src/schemas/notifications.tspackages/shared/src/utils/notification-text.tsapps/backend/src/services/notifications/helpers.tsapps/backend/src/services/notifications/NotificationService.tspackages/transactional/src/email-templates.tspackages/transactional/emails/order-expired-email.tsxpackages/transactional/src/index.tsWhen adding a new notification type, ensure:
packages/shared/src/schemas/notifications.tsNotificationActionType enum (if using new action type)packages/shared/src/schemas/notifications.tspackages/shared/src/schemas/notifications.tspackages/shared/src/utils/notification-text.ts (for title/description)packages/transactional/emails/ (if using email channel)packages/transactional/src/index.tspackages/transactional/src/email-templates.ts implementationcd apps/backend && pnpm generate:db (after migration)NotificationType from shared)apps/backend/src/services/notifications/helpers.ts (optional but recommended)pnpm tsoa:both# After creating migration for new notification type
cd apps/backend && pnpm kysely:migrate && pnpm generate:db
# Regenerate TSOA routes and OpenAPI spec
cd apps/backend && pnpm tsoa:both
# Regenerate frontend API types
cd apps/frontend && pnpm generate:api
The type system ensures: