Revendiste Notification System
A type-safe, scalable notification system with email template support using React Email.
Quick Start
typescript1import {NotificationService} from '~/services/notifications'; 2import { 3 notifySellerTicketSold, 4 notifyOrderConfirmed, 5 notifyIdentityVerificationCompleted, 6 notifyIdentityVerificationRejected, 7} from '~/services/notifications/helpers'; 8 9// Simple usage with helper function 10await notifySellerTicketSold(notificationService, { 11 sellerUserId: userId, 12 listingId: listing.id, 13 eventName: order.event.name, 14 eventStartDate: new Date(order.event.eventStartDate), 15 eventEndDate: new Date(order.event.eventEndDate), 16 platform: 'ticketmaster', 17 qrAvailabilityTiming: '12h', 18 ticketCount: 2, 19}); 20 21// Identity verification completed (user can now sell) 22await notifyIdentityVerificationCompleted(notificationService, { 23 userId: user.id, 24}); 25 26// Identity verification rejected by admin 27await notifyIdentityVerificationRejected(notificationService, { 28 userId: user.id, 29 rejectionReason: 'El documento no es legible', 30 canRetry: true, 31}); 32 33// Or create directly (title and description are auto-generated from metadata) 34await notificationService.createNotification({ 35 userId: userId, 36 type: 'order_confirmed', 37 channels: ['in_app', 'email'], 38 actions: [ 39 { 40 type: 'view_order', 41 label: 'Ver orden', 42 url: `${APP_BASE_URL}/cuenta/tickets?orderId=${orderId}`, 43 }, 44 ], 45 metadata: { 46 type: 'order_confirmed', 47 orderId, 48 eventName, 49 totalAmount: '100.00', 50 subtotalAmount: '90.00', 51 platformCommission: '10.00', 52 vatOnCommission: '0.00', 53 currency: 'EUR', 54 items: [], 55 }, 56});
Note: Title and description are automatically generated from the notification type and metadata - you don't need to provide them when creating notifications.
Debounced/Batched 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.
How It Works
- Debounce Key: Notifications are grouped by a unique key (e.g.,
document_uploaded:{orderId}) - Time Window: When the first notification is added to a batch, a time window starts (default: 5 minutes)
- Batching: Additional notifications with the same key are added to the batch
- Processing: When the window ends, a cronjob merges all items into a single notification
- Final Notification: The merged notification is sent (e.g., "3 de tus 4 entradas están listas")
Using Debounced Notifications
typescript1// notifyDocumentUploaded automatically uses debouncing 2await notifyDocumentUploaded(notificationService, { 3 buyerUserId: buyer.id, 4 orderId: order.id, 5 eventName: 'Concert', 6 ticketCount: 1, 7}); 8 9// Or create a debounced notification directly 10await notificationService.createDebouncedNotification({ 11 userId: buyer.id, 12 type: 'document_uploaded', 13 channels: ['in_app', 'email'], 14 metadata: { ... }, 15 actions: [ ... ], 16 debounce: { 17 key: `document_uploaded:${orderId}`, // Unique grouping key 18 windowMs: 5 * 60 * 1000, // 5 minute window 19 }, 20});
Database Tables
- notification_batches: Groups related notifications by debounce key
- notification_batch_items: Individual items within a batch (metadata, actions)
Cronjob Processing
The process-pending-notifications cronjob handles both:
- Pending batches: Merges items when window ends, creates final notification
- Pending notifications: Retries failed sends with exponential backoff
Notification Types with Debouncing
document_uploaded→ merged intodocument_uploaded_batch
Available Helper Functions
Helper 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 failure
Payout Helpers:
notifyPayoutCompleted()- Payout completednotifyPayoutFailed()- Payout failednotifyPayoutCancelled()- Payout cancelled
Identity Verification Helpers:
notifyIdentityVerificationCompleted()- Verification successfulnotifyIdentityVerificationRejected()- Admin rejected verificationnotifyIdentityVerificationFailed()- System failure (in_app only)notifyIdentityVerificationManualReview()- Needs admin review (in_app only)
System Architecture
Type-Safe Notification System
The notification system uses discriminated unions and function overloading to provide full type safety:
-
Metadata Schemas (
packages/shared/src/schemas/notifications.ts)- All notification schemas are in the shared package
- Each notification type has its own Zod schema
- Discriminated union ensures type safety
- Metadata type must match notification type
- Includes base schemas, action schemas, and notification schemas
-
Email Templates (
packages/transactional/)- React Email components in
emails/directory - Each template exports its prop types
- Type-safe template mapping via function overloading
- React Email components in
-
Template Builder (
apps/backend/src/services/notifications/email-template-builder.ts)- Parses metadata using correct schema from shared package
- Maps notification types to email templates
- Renders React components to HTML (no React in backend)
-
Database Types (
packages/shared/src/types/db.d.ts)- Generated database types from Kysely
NotificationTypeenum is generated from databaseNotificationmodel type is inapps/backend/src/types/models.tsasSelectable<Notifications>
Notification Types
Current Types
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 expiration
Payment Notifications:
payment_failed- Payment failurepayment_succeeded- Payment success
Payout Notifications:
payout_processing- Payout started processing (legacy, maps to completed)payout_completed- Payout completed successfullypayout_failed- Payout failedpayout_cancelled- Payout cancelled
Identity 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 review
Auth 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 alert
Notification Action Types
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 page
Notification Channels
in_app- In-app notifications (bell icon)email- Email notificationssms- SMS notifications (future)
Channel Selection Strategy
Use ['in_app', 'email'] for high-value notifications:
- Order confirmations (
order_confirmed) - Payment failures (
payment_failed) - Payout completions (
payout_completed) - Identity verification completed (
identity_verification_completed) - Identity verification rejected (
identity_verification_rejected) - Document uploads (
document_uploaded)
Use ['in_app'] only to save email costs:
- Informational updates that don't require immediate action
- Manual review status (
identity_verification_manual_review) - System failures where user can retry in UI (
identity_verification_failed) - Transient states
typescript1// Example: in_app only (no email cost) 2await service.createNotification({ 3 userId, 4 type: 'identity_verification_manual_review', 5 channels: ['in_app'], // No email - just informational 6 actions: null, 7 metadata: {type: 'identity_verification_manual_review'}, 8}); 9 10// Example: high-value notification (email + in_app) 11await service.createNotification({ 12 userId, 13 type: 'identity_verification_completed', 14 channels: ['in_app', 'email'], // Email is valuable here 15 actions: [{type: 'publish_tickets', label: 'Publicar entradas', url: '...'}], 16 metadata: {type: 'identity_verification_completed'}, 17});
Notification Status
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)
Channel-Level Tracking
Each notification tracks delivery status per channel:
channelStatus(JSONB): Tracks status for each channel individually- Format:
{"email": {"status": "sent", "sentAt": "..."}, "in_app": {"status": "failed", "error": "..."}} - Allows partial success (e.g., email sent but SMS failed)
- Overall notification status is
sentif any channel succeeds
- Format:
Retry Mechanism
retryCount(integer): Tracks number of retry attempts (max 5)- Exponential backoff: Wait time increases with each retry
- Retry 0: 5 minutes
- Retry 1: 10 minutes
- Retry 2: 20 minutes
- Retry 3: 40 minutes
- Retry 4: 80 minutes
- Cron job processes pending notifications every 5 minutes
- Processes in parallel batches (10 at a time) for better throughput
Adding a New Notification Type
To add a new notification type, follow these steps:
Step 1: Define Metadata Schema
In packages/shared/src/schemas/notifications.ts:
typescript1// 1. Add metadata schema 2export const MyNewNotificationMetadataSchema = z.object({ 3 type: z.literal('my_new_notification'), 4 // Add your fields here 5 orderId: z.uuid(), 6 eventName: z.string(), 7 customField: z.string(), 8}); 9 10// 2. Add action schema (if needed) 11// First, add your new action type to NotificationActionType enum if needed: 12export const NotificationActionType = z.enum([ 13 'upload_documents', 14 'view_order', 15 'retry_payment', 16 'view_payout', 17 'start_verification', 18 'publish_tickets', 19 'my_new_action', // Add your new action type here 20]); 21 22export const MyNewNotificationActionsSchema = z 23 .array( 24 BaseActionSchema.extend({ 25 type: z.literal('my_new_action'), // Use your specific action type 26 label: z.string(), 27 url: z.url(), // Use z.url() not z.string().url() 28 }), 29 ) 30 .nullable(); 31 32// 3. Add notification schema 33export const MyNewNotificationSchema = BaseNotificationSchema.extend({ 34 type: z.literal('my_new_notification'), 35 metadata: MyNewNotificationMetadataSchema, 36 actions: MyNewNotificationActionsSchema, 37}); 38 39// 4. Add to discriminated unions 40export const NotificationMetadataSchema = z.discriminatedUnion('type', [ 41 // ... existing schemas 42 MyNewNotificationMetadataSchema, // Add here 43]); 44 45export const NotificationSchema = z.discriminatedUnion('type', [ 46 // ... existing schemas 47 MyNewNotificationSchema, // Add here 48]);
Step 2: Create Email Template Component
In packages/transactional/emails/my-new-notification-email.tsx:
typescript1import {Button, Section, Text} from '@react-email/components'; 2import {BaseEmail} from './base-template'; 3 4export interface MyNewNotificationEmailProps { 5 eventName: string; 6 orderId: string; 7 customField: string; 8 appBaseUrl?: string; 9} 10 11export const MyNewNotificationEmail = ({ 12 eventName, 13 orderId, 14 customField, 15 appBaseUrl, 16}: MyNewNotificationEmailProps) => ( 17 <BaseEmail 18 title="My New Notification" 19 preview={`Notification for ${eventName}`} 20 appBaseUrl={appBaseUrl} 21 > 22 <Text className="text-foreground mb-4">Content here...</Text> 23 </BaseEmail> 24); 25 26MyNewNotificationEmail.PreviewProps = { 27 eventName: 'Example Event', 28 orderId: '123', 29 customField: 'example', 30} as MyNewNotificationEmailProps; 31 32export default MyNewNotificationEmail;
Step 3: Export Template and Props
In packages/transactional/src/index.ts:
typescript1// Export the component 2export * from '../emails/my-new-notification-email'; 3 4// Export prop types 5export type {MyNewNotificationEmailProps} from '../emails/my-new-notification-email';
Step 4: Add to Email Template Mapping
In packages/transactional/src/email-templates.ts:
typescript1// 1. Import the component and props 2import { 3 MyNewNotificationEmail as MyNewNotificationEmailComponent, 4 type MyNewNotificationEmailProps, 5} from '../emails/my-new-notification-email'; 6import type {NotificationType, TypedNotificationMetadata} from '@revendiste/shared'; 7 8// Note: NotificationType is now imported from @revendiste/shared (generated from database) 9 10// 2. Add switch case in implementation 11case 'my_new_notification': { 12 const meta = metadata as TypedNotificationMetadata<'my_new_notification'>; 13 return { 14 Component: MyNewNotificationEmailComponent, 15 props: { 16 eventName: meta?.eventName || 'el evento', 17 orderId: meta?.orderId || '', 18 customField: meta?.customField || '', 19 appBaseUrl, 20 }, 21 }; 22}
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.
Step 5: Add to Email Template Builder
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.
Step 6: Update Database Enum (Migration)
Create a migration to add the new enum value using PostgreSQL's ALTER TYPE ... ADD VALUE:
typescript1// In migration file 2export async function up(db: Kysely<any>): Promise<void> { 3 // Add new value to the enum (appends to the end by default) 4 await sql` 5 ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'my_new_notification' 6 `.execute(db); 7}
Options for enum value positioning:
sql1-- Add at the end (default) 2ALTER TYPE notification_type ADD VALUE 'my_new_notification'; 3 4-- Add before an existing value 5ALTER TYPE notification_type ADD VALUE 'my_new_notification' BEFORE 'order_confirmed'; 6 7-- Add after an existing value 8ALTER TYPE notification_type ADD VALUE 'my_new_notification' AFTER 'payment_succeeded'; 9 10-- Use IF NOT EXISTS to avoid errors if value already exists 11ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'my_new_notification';
Important notes:
ALTER TYPE ... ADD VALUEcannot be executed inside a transaction block in PostgreSQL < 12- In PostgreSQL 12+, it can run inside a transaction but the new value cannot be used until after the transaction commits
- For Kysely migrations, this is usually fine since each migration runs in its own transaction
- See PostgreSQL ALTER TYPE documentation for more details
After running the migration, regenerate database types:
bash1cd apps/backend && pnpm generate:db
This updates NotificationType in packages/shared/src/types/db.d.ts.
Step 7: Update Repository Interface
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:
typescript1import type {NotificationType} from '~/shared'; 2 3export interface CreateNotificationData { 4 // ... 5 type: NotificationType; // Uses generated enum type 6 // ... 7}
If the interface uses a union type, update it to include the new value, or better yet, import NotificationType from ~/shared.
Step 8: Add Text Generation Function
In packages/shared/src/utils/notification-text.ts, add a case to generateNotificationText():
typescript1case 'my_new_notification': { 2 const meta = metadata as TypedNotificationMetadata<'my_new_notification'>; 3 return { 4 title: 'My New Notification', 5 description: `Notification for ${meta.eventName}`, 6 }; 7}
Note: Title and description are generated from metadata, not stored in the database. This function is called automatically when notifications are created or retrieved.
Step 9: (Optional) Create Helper Function
In apps/backend/src/services/notifications/helpers.ts:
typescript1export async function notifyMyNewNotification( 2 service: NotificationService, 3 params: { 4 userId: string; 5 orderId: string; 6 eventName: string; 7 customField: string; 8 }, 9) { 10 return await service.createNotification({ 11 userId: params.userId, 12 type: 'my_new_notification', 13 channels: ['in_app', 'email'], 14 actions: [ 15 { 16 type: 'view_order', 17 label: 'View Details', 18 url: `${APP_BASE_URL}/orders/${params.orderId}`, 19 }, 20 ], 21 metadata: { 22 type: 'my_new_notification' as const, 23 orderId: params.orderId, 24 eventName: params.eventName, 25 customField: params.customField, 26 }, 27 }); 28}
Note: Helper functions don't need to provide title or description - they're automatically generated from the metadata.
Type System Architecture
Shared Package Organization
All notification schemas are in the shared package (packages/shared/src/schemas/notifications.ts):
- Base schemas (
BaseNotificationSchema,BaseActionSchema) - Metadata schemas for each notification type
- Action schemas for each notification type
- Notification schemas for each notification type
- Discriminated unions (
NotificationMetadataSchema,NotificationSchema) - All related TypeScript types
Benefits:
- Single source of truth for notification schemas
- Shared between backend and transactional packages
- Type-safe across the monorepo
- Easy to maintain and extend
Discriminated Union Pattern
The notification system uses discriminated unions for type safety:
typescript1// Each notification type has its own metadata schema (in shared package) 2export const TicketSoldSellerMetadataSchema = z.object({ 3 type: z.literal('ticket_sold_seller'), // Discriminator 4 listingId: z.uuid(), 5 eventName: z.string(), 6 eventStartDate: z.string(), 7 ticketCount: z.number().int().positive(), 8 platform: z.string(), 9 qrAvailabilityTiming: z.custom<QrAvailabilityTiming>().nullable().optional(), 10 shouldPromptUpload: z.boolean(), 11}); 12 13// Discriminated union ensures type safety 14export const NotificationMetadataSchema = z.discriminatedUnion('type', [ 15 TicketSoldSellerMetadataSchema, 16 DocumentReminderMetadataSchema, 17 DocumentUploadedMetadataSchema, 18 OrderConfirmedMetadataSchema, 19 // ... other schemas 20]); 21 22// TypeScript infers the correct type based on 'type' field 23type Metadata = z.infer<typeof NotificationMetadataSchema>; 24// Metadata is: TicketSoldSellerMetadata | DocumentReminderMetadata | ...
Database Type Integration
NotificationTypeis generated from the database enum inpackages/shared/src/types/db.d.tsNotificationmodel type is inapps/backend/src/types/models.tsasSelectable<Notifications>- Always run
pnpm generate:dbafter migrations to update types
Email Template Function
The getEmailTemplate() function maps notification types to their email templates:
typescript1// Single function signature (no overloading needed) 2export function getEmailTemplate<T extends NotificationType>( 3 props: EmailTemplateProps<T>, 4): { 5 Component: React.ComponentType<any>; 6 props: Record<string, any>; 7}; 8 9// Switch statement handles type mapping 10switch (notificationType) { 11 case 'ticket_sold_seller': { 12 const meta = metadata as TypedNotificationMetadata<'ticket_sold_seller'>; 13 return { 14 Component: SellerTicketSoldEmailComponent, 15 props: { 16 eventName: meta?.eventName || 'el evento', 17 eventStartDate: meta?.eventStartDate || new Date().toISOString(), 18 ticketCount: meta?.ticketCount || 1, 19 uploadUrl: meta?.shouldPromptUpload ? uploadUrl : undefined, 20 appBaseUrl, 21 }, 22 }; 23 } 24 // ... other cases 25}
Note: Function overloading was simplified to a single signature with a switch statement, which is sufficient for runtime type mapping.
Title and Description Generation
Title and description are automatically generated from metadata - they are not stored in the database:
generateNotificationText()function inpackages/shared/src/utils/notification-text.ts- Called automatically when notifications are created (for validation) and retrieved (for API responses)
- Each notification type has its own text generation logic based on metadata
- Ensures consistency and eliminates the need to store redundant text data
Notification Validation Flow
When creating a notification, the system validates data in this order:
-
Metadata Validation (
NotificationService.createNotification)- Validates metadata against
NotificationMetadataSchema(discriminated union) - Ensures metadata
typefield matches notificationtype - Throws
ValidationErrorif validation fails
- Validates metadata against
-
Actions Validation
- Validates actions against
NotificationActionsSchema(generic array schema) - Converts
nulltoundefinedfor consistency
- Validates actions against
-
Title/Description Generation
- Generates title and description from metadata using
generateNotificationText() - Metadata is required for this step (throws error if missing)
- Generates title and description from metadata using
-
Full Notification Validation
- Validates complete notification structure against type-specific schema from
NotificationSchema(discriminated union) - Ensures all fields match the expected structure for the notification type
- Validates complete notification structure against type-specific schema from
-
Database Creation
- Creates notification record with validated data
- Repository assumes data is already validated (no additional validation)
Email Template Builder Flow
-
Parse Metadata (
parseNotificationMetadata)- Validates metadata against correct schema
- Returns typed metadata matching notification type
- Throws error if metadata type doesn't match notification type
-
Build Template (
buildEmailTemplate)- Maps notification type to email template via
getEmailTemplate() - Renders React component to HTML in transactional package using
renderEmail() - Generates both HTML and plain text versions
- Returns HTML and plain text ready to send
- Maps notification type to email template via
-
Send Email (
NotificationService.sendEmailNotification)- Calls template builder
- Generates email subject from notification title (via
generateNotificationText()) - Sends via email provider (Resend, Console, etc.)
Email Provider Configuration
Environment Variables
env1EMAIL_PROVIDER=resend # Options: console, resend 2EMAIL_FROM=noreply@yourdomain.com 3RESEND_API_KEY=re_your_api_key_here # Required when EMAIL_PROVIDER=resend
Provider Pattern
The system uses a factory pattern to select email providers:
- Console Provider (default): Logs emails to console for development
- Resend Provider: Production-ready email service with excellent deliverability
Switching Providers
Change EMAIL_PROVIDER environment variable - no code changes needed.
API Endpoints
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 notification
Key Patterns
Title and Description Auto-Generation
Title and description are automatically generated from metadata - you never provide them:
typescript1// ✅ CORRECT - Title/description auto-generated 2await notificationService.createNotification({ 3 userId: userId, 4 type: 'order_confirmed', 5 channels: ['in_app', 'email'], 6 metadata: { 7 type: 'order_confirmed', 8 orderId, 9 eventName, 10 // ... other fields 11 }, 12}); 13// Title: "Orden confirmada" 14// Description: Generated from metadata 15 16// ❌ WRONG - Don't provide title/description 17await notificationService.createNotification({ 18 userId: userId, 19 type: 'order_confirmed', 20 title: 'Custom title', // Not accepted! 21 description: 'Custom description', // Not accepted! 22 // ... 23});
Fire-and-Forget Processing
Notifications are sent asynchronously - your business logic doesn't wait:
typescript1// ✅ CORRECT - Non-blocking 2await notificationService.createNotification({...}); 3// Your code continues immediately 4 5// The notification is sent in the background
External API Calls Outside Transactions
Email sending happens outside database transactions (follows Transaction Safety pattern):
typescript1// ✅ CORRECT - Email outside transaction 2await this.repository.executeTransaction(async trx => { 3 // Database operations only 4 await repo.create({...}); 5}); 6 7// Email sent after transaction commits 8await notificationService.createNotification({...});
Error Handling
- Failed notifications are marked with status
failedand error message - Channel-level tracking: Each channel's status is tracked individually in
channelStatusJSONB field - Exponential backoff: Retries wait longer with each attempt (5min, 10min, 20min, 40min, 80min)
- Calculated as:
baseDelay * 2^retryCountwherebaseDelay = 5 minutes - Filtered in application code after querying from database
- Calculated as:
- Max 5 retries: Prevents infinite retry loops (retry count stored in
retryCountcolumn) - Cronjob automatically retries pending notifications in parallel batches (10 at a time)
- Errors are logged but don't break your business logic
- Partial success: If some channels succeed, notification is marked as
sent - Notifications are skipped if already processed (status
sentorfailed, or all channels already processed)
Template System Details
React Email Components
Email templates are React components in packages/transactional/emails/:
- Use
@react-email/componentsfor email-safe components - Export prop types for type safety
- Use
BaseEmailwrapper for consistent layout - Include
PreviewPropsfor React Email preview
Template Mapping
Templates are mapped via getEmailTemplate() in packages/transactional/src/email-templates.ts:
- Function overloading provides type safety
- Each notification type maps to its specific component
- Props are extracted from metadata and actions
- TypeScript ensures correct props for each template
Rendering Flow
- Backend calls
buildEmailTemplate()with typed metadata - Template builder calls
getEmailTemplate()(type-safe via overloading) - React component is rendered to HTML using
renderEmail()in transactional package - HTML is sent via email provider
Key Point: React stays in the transactional package - backend never imports React directly.
Best Practices
- Use helper functions for common notification types - they handle metadata structure correctly
- Keep notifications outside transactions - send after database operations complete
- Don't await notification creation - let it process in background (fire-and-forget)
- Use appropriate channels - email for important actions, in-app for updates (see Channel Selection Strategy)
- Include actions conditionally - some notifications may not need actions (e.g.,
notifySellerTicketSoldonly adds upload action if within time window) - Add metadata - store relevant IDs/context for future reference (required for title/description generation)
- Never provide title/description - they're auto-generated from metadata
- Export prop types - enables type safety in template mapping
- Follow discriminated union pattern - ensures metadata matches notification type
- Handle errors gracefully - notification failures are logged but don't break business logic
- Be conservative with emails - emails cost money; use
['in_app']only for informational updates - Add action types to enum - when creating notifications with actions, ensure action type is in
NotificationActionType
Notifications Without Email Templates
Some notifications only use in_app channel and don't need email templates:
typescript1// identity_verification_manual_review - in_app only 2await notifyIdentityVerificationManualReview(service, {userId}); 3 4// identity_verification_failed - in_app only (user can retry in UI) 5await notifyIdentityVerificationFailed(service, { 6 userId, 7 failureReason: 'No pudimos verificar tu identidad', 8 attemptsRemaining: 3, 9});
For these notifications:
- Skip Step 2 (email template creation)
- Skip Step 3 (template export)
- Skip Step 4 (email template mapping)
- Still need: metadata schema, action schema, notification schema, text generation, database enum, helper function
Maintenance Checklist
When adding a new notification type, ensure:
- Metadata schema defined in
packages/shared/src/schemas/notifications.ts - Action type added to
NotificationActionTypeenum (if using new action type) - Action schema added (if needed) in
packages/shared/src/schemas/notifications.ts - Notification schema added to discriminated union in
packages/shared/src/schemas/notifications.ts - Text generation function added in
packages/shared/src/utils/notification-text.ts(for title/description) - Email template component created with exported props in
packages/transactional/emails/(if using email channel) - Template exported from
packages/transactional/src/index.ts - Switch case added in
packages/transactional/src/email-templates.tsimplementation - Database enum updated (migration created and run)
- Types regenerated:
cd apps/backend && pnpm generate:db(after migration) - Repository interface updated (if using union type instead of
NotificationTypefrom shared) - Helper function created in
apps/backend/src/services/notifications/helpers.ts(optional but recommended) - API docs regenerated:
pnpm tsoa:both
Quick Commands
bash1# After creating migration for new notification type 2cd apps/backend && pnpm kysely:migrate && pnpm generate:db 3 4# Regenerate TSOA routes and OpenAPI spec 5cd apps/backend && pnpm tsoa:both 6 7# Regenerate frontend API types 8cd apps/frontend && pnpm generate:api
Type Safety Benefits
The type system ensures:
- ✅ Compile-time validation - TypeScript catches type mismatches
- ✅ Autocomplete support - IDE knows what props each template needs
- ✅ Refactoring safety - Changes propagate through type system
- ✅ Self-documenting - Types show exactly what each notification needs
- ✅ No runtime checks needed - Type system handles validation