diff --git a/config/notifications.ts b/config/notifications.ts index b9a4649..25faa2a 100644 --- a/config/notifications.ts +++ b/config/notifications.ts @@ -8,8 +8,14 @@ export const notificationConfig: NotificationConfig = { defaultChannel: process.env.DISCORD_DEFAULT_CHANNEL }, email: { - enabled: !!(process.env.EMAIL_ENABLED === 'true'), - provider: (process.env.EMAIL_PROVIDER as 'sendgrid' | 'ses') || 'sendgrid' + enabled: !!(process.env.LOOPS_API_KEY), + provider: 'loops', + apiKey: process.env.LOOPS_API_KEY, + templates: { + friend_request_existing_user: process.env.LOOPS_TEMPLATE_FRIEND_REQUEST_EXISTING || 'cmc3u8e020700z00iason0m0f', + friend_request_new_user: process.env.LOOPS_TEMPLATE_FRIEND_REQUEST_NEW || 'cmc6k356p2tf0zq0jg9y0atvr', + weekly_report: process.env.LOOPS_TEMPLATE_WEEKLY_REPORT || '' + } }, slack: { enabled: !!(process.env.SLACK_WEBHOOK_URL), @@ -27,7 +33,9 @@ export const notificationConfig: NotificationConfig = { weekly_report: ['discord'], payment_failed: ['discord'], checkout_completed: ['discord'], - subscription_cancelled: ['discord'] + subscription_cancelled: ['discord'], + subscription_expired: ['discord'], + friend_request: ['email'] } } diff --git a/docs/loops-email-notifications.md b/docs/loops-email-notifications.md new file mode 100644 index 0000000..67476d0 --- /dev/null +++ b/docs/loops-email-notifications.md @@ -0,0 +1,267 @@ +# Loops Email Notifications - Implementation Notes + +This document outlines key considerations and requirements for implementing batch email notifications through the Loops email provider. + +## 1. Unsubscribe Behavior + +### Expected Behavior + +Most email service providers (including Loops) typically handle unsubscribe management automatically at the API level. When a contact unsubscribes: + +- **Automatic suppression**: Loops should prevent emails from being sent to unsubscribed contacts, even if your application attempts to send to them +- **API response**: Loops will either return a success response without sending (soft fail) or return an error/warning indicating the contact is suppressed +- **No action required**: Your application doesn't need to filter unsubscribed users before calling the API + +### Verification Required + +**Action Item**: Verify Loops' exact behavior by checking their documentation for: + +- Suppression list functionality +- What happens when sending to unsubscribed contacts +- API response codes/format for suppressed sends +- Whether suppression is automatic or requires configuration + +### Template Configuration + +For marketing/broadcast emails (like weekly reports): + +- Ensure Loops templates include unsubscribe links (typically handled automatically) +- Verify unsubscribe links point to Loops' hosted unsubscribe page +- Confirm Loops manages the unsubscribe flow and updates contact preferences + +### Optional: Sync Unsubscribe Status + +Consider periodically syncing Loops' unsubscribe list to your database: + +- Query Loops API for unsubscribed contacts +- Update local `email_preferences` table (if implemented) +- Use for analytics/reporting purposes + +**Note**: Transactional emails (like friend requests) typically do not include unsubscribe functionality as they are user-triggered actions. + +--- + +## 2. Batch Send Interval Configuration + +### Current Implementation + +The `NotificationEngine.sendBatchNotifications()` method uses a **hardcoded 100ms delay** between individual sends: + +**Location**: `services/NotificationEngine.ts:200` + +```typescript +// Add small delay to avoid overwhelming notification services +await new Promise((resolve) => setTimeout(resolve, 100)); +``` + +### Weekly Report Consideration + +For weekly report emails sent to many users, you may want to: + +- **Increase the delay** (e.g., 500ms or 1000ms) to avoid rate limiting +- **Reduce load** on Loops API during batch processing +- **Spread out sends** over a longer time period + +### Implementation Options + +**Option A: Add to NotificationConfig** (Recommended) + +```typescript +// config/notifications.ts +export const notificationConfig: NotificationConfig = { + // ... existing config + batchDelays: { + weekly_report: 500, // 500ms for weekly reports + paid_user: 100, // 100ms for paid user notifications + new_user: 100, // 100ms for new user notifications + // Defaults to 100ms if not specified + }, +}; +``` + +--- + +## 3. Requirements for Batch Notifications via Loops + +### Current Implementation Status + +The infrastructure for batch notifications is **mostly complete** but needs minor modifications to support `weekly_report` type. + +### What's Already Working โœ… + +1. **LoopsNotificationProvider** (`services/providers/LoopsNotificationProvider.ts`) + + - Implements `NotificationProvider` interface + - Handles `weekly_report` notification type + - Formats payloads for Loops API + - Error handling and logging + +2. **Batch Processing Infrastructure** + + - `processNotificationsWithIdempotency()` - Generic batch processing with idempotency + - `NotificationEngine.sendBatchNotifications()` - Loops through users, sends individually + - `UserNotificationsRepo` - Tracks sent notifications per user per channel + +3. **Configuration** + - Loops API key via `LOOPS_API_KEY` environment variable + - Template IDs configurable via environment variables + - Email channel enabled automatically when API key is present + +### What Needs to Be Added ๐Ÿ”ง + +#### 1. Update Type Constraints + +**File**: `services/JobProcessors.ts:73-76` + +Current: + +```typescript +export const processNotificationsWithIdempotency = async ( + users: T[], + notificationType: 'paid_user' | 'new_user' | 'inactive_user', + // ... +``` + +Needs: + +- Add `weekly_report` to the `notificationType` union +- Add appropriate user record type for weekly report recipients + +#### 2. Add to NotificationEngine Switch Statement + +**File**: `services/NotificationEngine.ts:182-195` + +Current: + +```typescript +switch (notificationType) { + case "paid_user": + results = await this.sendPaidUserNotifications( + user as PaidUserRecord, + channels + ); + break; + case "new_user": + results = await this.sendNewUserNotifications( + user as NewUserRecord, + channels + ); + break; + case "inactive_user": + results = await this.sendInactiveUserNotifications( + user as InactiveUserRecord, + channels + ); + break; + default: + console.warn(`โš ๏ธ Unknown notification type: ${notificationType}`); +} +``` + +Needs: + +- Add `case 'weekly_report'` with appropriate handler method +- Create `sendWeeklyReportNotifications()` method in NotificationEngine + +#### 3. Create Database Query for Recipients + +**File**: New method in `repos/UserMonitoring.ts` (or similar) + +```typescript +async getWeeklyReportRecipients(): Promise { + // Query users who should receive weekly reports + // Consider: + // - Active users only? + // - Paid users only? + // - Users who haven't opted out? + // - Minimum activity threshold? +} +``` + +#### 4. Update Weekly Email Job Processor + +**File**: `services/JobProcessors.ts:362-417` + +Current implementation sends to a single "system" user. Needs to: + +- Fetch list of recipient users from database +- Call `processNotificationsWithIdempotency()` with user list +- Pass `'weekly_report'` as notification type +- Include personalized data per user (if applicable) + +Example: + +```typescript +export const processWeeklyEmailReminder = async ( + job: Job +): Promise => { + try { + console.log("๐Ÿ“ง Processing weekly email reminder job..."); + + // Fetch all users who should receive weekly reports + const recipients = await UserMonitoringRepo.getWeeklyReportRecipients(); + console.log(`๐Ÿ“Š Found ${recipients.length} weekly report recipients`); + + // Use batch processing with idempotency + const results = await processNotificationsWithIdempotency( + recipients, + "weekly_report", + (user) => + `weekly_report_${user.id}_${new Date().toISOString().split("T")[0]}`, + "๐Ÿ“ง" + ); + + console.log( + `โœ… Weekly report completed - ${results.newNotifications} emails sent` + ); + + return { + success: true, + message: `Weekly report sent to ${results.newNotifications} users`, + data: results, + processedAt: new Date(), + }; + } catch (error) { + console.error("โŒ Error processing weekly email reminder:", error); + return { + success: false, + message: `Failed to process weekly email reminder: ${error}`, + processedAt: new Date(), + }; + } +}; +``` + +### Benefits of Batch Approach + +โœ… **Idempotency**: Won't send duplicate emails if job runs twice +โœ… **Per-user tracking**: Records in `user_notifications` table +โœ… **Personalization**: Each user can receive customized data +โœ… **Error resilience**: Individual failures don't stop the entire batch +โœ… **Rate limiting**: Built-in delays prevent overwhelming Loops API +โœ… **Auditability**: Full history of who received what and when + +### Testing Considerations + +1. **Test with small batches first** - Use a limited user set for initial testing +2. **Monitor Loops rate limits** - Check their API documentation for request limits +3. **Verify idempotency** - Ensure duplicate job runs don't send duplicate emails +4. **Check unsubscribe flow** - Manually test unsubscribe and verify suppression +5. **Validate template data** - Ensure all template variables are populated correctly + +--- + +## Summary Checklist + +### Before Implementing Weekly Report Emails: + +- [ ] Verify Loops' unsubscribe behavior in their documentation +- [ ] Decide on batch send interval (100ms vs 500ms vs configurable) +- [ ] Create database query for weekly report recipients +- [ ] Add `weekly_report` to type constraints in `processNotificationsWithIdempotency` +- [ ] Add `sendWeeklyReportNotifications()` method to NotificationEngine +- [ ] Update `processWeeklyEmailReminder` to use batch processing +- [ ] Configure `LOOPS_TEMPLATE_WEEKLY_REPORT` environment variable +- [ ] Create/design weekly report email template in Loops dashboard +- [ ] Test with small user batch first +- [ ] Monitor Loops API usage and adjust delays if needed diff --git a/repos/__tests__/License.test.ts b/repos/__tests__/License.test.ts index 4cc41df..3fc8218 100644 --- a/repos/__tests__/License.test.ts +++ b/repos/__tests__/License.test.ts @@ -102,14 +102,4 @@ describe('LicenseRepo', () => { expect(new Date(result?.expiration_date || '')).toEqual(futureExpiration) }) }) - - - describe('updateLicenseByStripePaymentId', () => { - it('should update a license', async () => { - const result = await LicenseRepo.updateLicenseByStripePaymentId('pi_test123', 'expired') - expect(result).not.toBeNull() - expect(result?.user_id).toBe(user1Id) - expect(result?.status).toBe('expired') - }) - }) }) \ No newline at end of file diff --git a/services/EmailService.ts b/services/EmailService.ts index f36edc6..a7805b6 100644 --- a/services/EmailService.ts +++ b/services/EmailService.ts @@ -1,15 +1,6 @@ -interface LoopsEmailPayload { - transactionalId: string - email: string - dataVariables: Record -} - -interface PaymentFailureEmailData { - customerEmail: string - customerName?: string - amountDue: number - currency: string -} +import { NotificationEngine } from './NotificationEngine' +import { getNotificationConfig } from '../config/notifications' +import type { NotificationPayload, BaseUserRecord } from '../types/notifications' interface FriendRequestEmailData { toEmail: string @@ -18,67 +9,43 @@ interface FriendRequestEmailData { existingUser?: boolean } -const sendLoopsEmail = async (payload: LoopsEmailPayload): Promise => { +const getNotificationEngine = (): NotificationEngine => { + const config = getNotificationConfig() + return new NotificationEngine(config) +} + +const sendFriendRequestEmail = async (data: FriendRequestEmailData): Promise => { try { - const loopsApiKey = process.env.LOOPS_API_KEY - if (!loopsApiKey) { - console.error('LOOPS_API_KEY not configured, skipping email send') - return + const notificationEngine = getNotificationEngine() + + const payload: NotificationPayload = { + type: 'friend_request', + user: { + id: data.requestId, // Use request ID as the user ID for tracking + email: data.toEmail + } as BaseUserRecord, + referenceId: `friend_request_${data.requestId}`, + data: { + fromEmail: data.fromEmail, + requestId: data.requestId, + existingUser: data.existingUser + } } - const response = await fetch('https://app.loops.so/api/v1/transactional', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${loopsApiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload) - }) + const results = await notificationEngine.sendNotification(payload, ['email']) - if (!response.ok) { - const errorText = await response.text() - console.error('Failed to send email via Loops:', response.status, errorText) - return + const success = results.some(r => r.success) + if (success) { + console.log('โœ… Friend request email sent successfully via NotificationEngine') + } else { + console.error('โŒ Failed to send friend request email:', results[0]?.error) } - - const result = await response.json() - console.log('Email sent successfully via Loops:', result) - } catch (error) { - console.error('Failed to send email:', error) + console.error('โŒ Failed to send friend request email:', error) // Don't throw here - we don't want email failures to prevent other operations } } -// const sendPaymentFailureEmail = async (data: PaymentFailureEmailData): Promise => { -// const payload: LoopsEmailPayload = { -// transactionalId: 'YOUR_PAYMENT_FAILURE_TEMPLATE_ID', // Replace with your Loops template ID -// email: data.customerEmail, -// dataVariables: { -// customer_email: data.customerEmail, -// customer_name: data.customerName || '', -// formatted_amount: `${data.currency.toUpperCase()} $${(data.amountDue / 100).toFixed(2)}` -// } -// } - -// await sendLoopsEmail(payload) -// } - -const sendFriendRequestEmail = async (data: FriendRequestEmailData): Promise => { - const payload: LoopsEmailPayload = { - transactionalId: data.existingUser ? 'cmc3u8e020700z00iason0m0f' : 'cmc6k356p2tf0zq0jg9y0atvr', - email: data.toEmail, - dataVariables: { - to_email: data.toEmail, - from_email: data.fromEmail, - request_id: data.requestId - } - } - - await sendLoopsEmail(payload) -} - export const EmailService = { - sendFriendRequestEmail, - sendLoopsEmail + sendFriendRequestEmail } \ No newline at end of file diff --git a/services/NotificationEngine.ts b/services/NotificationEngine.ts index 64f3df1..224f40f 100644 --- a/services/NotificationEngine.ts +++ b/services/NotificationEngine.ts @@ -10,6 +10,7 @@ import type { NotificationConfig } from '../types/notifications' import { DiscordNotificationProvider } from './providers/DiscordNotificationProvider' +import { LoopsNotificationProvider } from './providers/LoopsNotificationProvider' import { NotificationService } from './NotificationService' export class NotificationEngine { @@ -29,7 +30,17 @@ export class NotificationEngine { console.log('โœ… Discord notification provider initialized') } - // TODO: Initialize other providers (email, slack, sms) when implemented + // Initialize Loops (email) provider if enabled + if (this.config.channels.email.enabled && this.config.channels.email.apiKey) { + const loopsProvider = new LoopsNotificationProvider( + this.config.channels.email.apiKey, + this.config.channels.email.templates || {} + ) + this.providers.set('email', loopsProvider) + console.log('โœ… Loops email notification provider initialized') + } + + // TODO: Initialize other providers (slack, sms) when implemented console.log(`๐Ÿ“ก Notification engine initialized with ${this.providers.size} providers`) } diff --git a/services/__tests__/LicenseService.test.ts b/services/__tests__/LicenseService.test.ts index f596143..650675d 100644 --- a/services/__tests__/LicenseService.test.ts +++ b/services/__tests__/LicenseService.test.ts @@ -5,7 +5,7 @@ import { LicenseRepo, type License } from "../../repos/License" // Mock the LicenseRepo module mock.module("../../repos/License", () => ({ LicenseRepo: { - getActiveLicenseByUserId: mock(), + getLicenseByUserId: mock(), createLicense: mock() } })) @@ -26,19 +26,19 @@ describe('LicenseService', () => { describe('startFreeTrial', () => { it('should throw error when user already has an active license', async () => { - // Mock getActiveLicenseByUserId to return an existing license - LicenseRepo.getActiveLicenseByUserId = mock(() => Promise.resolve(mockLicense)) + // Mock getLicenseByUserId to return an existing license + LicenseRepo.getLicenseByUserId = mock(() => Promise.resolve(mockLicense)) await expect(LicenseService.startFreeTrial('test-user-id')).rejects.toThrow('User already has a license') - - expect(LicenseRepo.getActiveLicenseByUserId).toHaveBeenCalledWith('test-user-id') + + expect(LicenseRepo.getLicenseByUserId).toHaveBeenCalledWith('test-user-id') }) it('should start a free trial when user has no existing license', async () => { - LicenseRepo.getActiveLicenseByUserId = mock(() => Promise.resolve(null)) + LicenseRepo.getLicenseByUserId = mock(() => Promise.resolve(null)) LicenseRepo.createLicense = mock(() => Promise.resolve(mockLicense)) const result = await LicenseService.startFreeTrial('test-user-id') expect(result).toBeDefined() - expect(LicenseRepo.getActiveLicenseByUserId).toHaveBeenCalledWith('test-user-id') + expect(LicenseRepo.getLicenseByUserId).toHaveBeenCalledWith('test-user-id') expect(LicenseRepo.createLicense).toHaveBeenCalledWith({ user_id: 'test-user-id', license_type: 'free_trial', diff --git a/services/providers/LoopsNotificationProvider.ts b/services/providers/LoopsNotificationProvider.ts new file mode 100644 index 0000000..5f64638 --- /dev/null +++ b/services/providers/LoopsNotificationProvider.ts @@ -0,0 +1,119 @@ +import type { + NotificationProvider, + NotificationPayload, + NotificationResult, + LoopsEmailPayload, + LoopsTemplateConfig +} from '../../types/notifications' + +export class LoopsNotificationProvider implements NotificationProvider { + readonly name = 'email' as const + + constructor( + private apiKey: string, + private templates: LoopsTemplateConfig = {} + ) { + if (!apiKey) { + throw new Error('Loops API key is required') + } + } + + async send(payload: NotificationPayload): Promise { + try { + const loopsPayload = this.formatPayload(payload) + + if (!loopsPayload) { + return { + success: false, + message: `No email template configured for ${payload.type}`, + userId: payload.user.id, + referenceId: payload.referenceId, + channel: this.name, + error: 'No template configured', + timestamp: new Date() + } + } + + const response = await fetch('https://app.loops.so/api/v1/transactional', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(loopsPayload), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Loops API error: ${response.status} - ${errorText}`) + } + + return { + success: true, + message: `Email notification sent successfully via Loops for ${payload.type}`, + userId: payload.user.id, + referenceId: payload.referenceId, + channel: this.name, + notificationId: `loops_${Date.now()}_${payload.user.id}`, + timestamp: new Date() + } + } catch (error) { + console.error('โŒ Loops email notification failed:', error) + return { + success: false, + message: `Failed to send email notification: ${error instanceof Error ? error.message : 'Unknown error'}`, + userId: payload.user.id, + referenceId: payload.referenceId, + channel: this.name, + error: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date() + } + } + } + + private formatPayload(payload: NotificationPayload): LoopsEmailPayload | null { + switch (payload.type) { + case 'friend_request': + return this.formatFriendRequestEmail(payload) + case 'weekly_report': + return this.formatWeeklyReportEmail(payload) + default: + console.warn(`โš ๏ธ No email template for notification type: ${payload.type}`) + return null + } + } + + private formatFriendRequestEmail(payload: NotificationPayload): LoopsEmailPayload { + const isExistingUser = payload.data?.existingUser ?? false + const templateId = isExistingUser + ? this.templates.friend_request_existing_user || 'cmc3u8e020700z00iason0m0f' + : this.templates.friend_request_new_user || 'cmc6k356p2tf0zq0jg9y0atvr' + + return { + transactionalId: templateId, + email: payload.user.email, + dataVariables: { + to_email: payload.user.email, + from_email: payload.data?.fromEmail || '', + request_id: payload.data?.requestId || payload.referenceId + } + } + } + + private formatWeeklyReportEmail(payload: NotificationPayload): LoopsEmailPayload | null { + const templateId = this.templates.weekly_report + if (!templateId) { + console.warn('โš ๏ธ Weekly report email template ID not configured') + return null + } + + return { + transactionalId: templateId, + email: payload.user.email, + dataVariables: { + timestamp: payload.data?.timestamp || new Date().toISOString(), + ...payload.data // Include any additional data passed in + } + } + } +} diff --git a/tests/e2e/checkout.test.ts b/tests/e2e/checkout.test.ts index b7cc6b9..d11a1cd 100644 --- a/tests/e2e/checkout.test.ts +++ b/tests/e2e/checkout.test.ts @@ -101,7 +101,11 @@ describe('Checkout API', () => { describe('return validation', () => { it('should return 200 when licenseType is valid', async () => { + let originalGetLicenseByUserId = LicenseService.getActiveLicense + let originalCreateCheckoutSession = StripeService.createCheckoutSession + LicenseService.getActiveLicense = async () => null + StripeService.createCheckoutSession = async () => 'https://checkout.stripe.com/test-session' const response = await request(app) .post('/api/checkout/create') @@ -111,6 +115,9 @@ describe('Checkout API', () => { expect(response.body.success).toBe(true) expect(response.body.data).toHaveProperty('url') + + LicenseService.getActiveLicense = originalGetLicenseByUserId + StripeService.createCheckoutSession = originalCreateCheckoutSession }) }) diff --git a/tests/services/WebhookService.test.ts b/tests/services/WebhookService.test.ts index 083f06b..8d70464 100644 --- a/tests/services/WebhookService.test.ts +++ b/tests/services/WebhookService.test.ts @@ -6,7 +6,6 @@ import type Stripe from 'stripe' mock.module('../../repos/License.js', () => ({ LicenseRepo: { createLicense: mock(() => Promise.resolve({ id: 'license-123' })), - updateLicenseByStripePaymentId: mock(() => Promise.resolve({ id: 'license-123' })), getFreeTrialLicenseByUserId: mock(() => Promise.resolve(null)), getExistingSubscriptionLicenseByUserId: mock(() => Promise.resolve(null)), updateLicense: mock(() => Promise.resolve({ id: 'trial-license-456' })) @@ -36,7 +35,6 @@ describe('WebhookService', () => { // Clear call history for all mocks (LicenseRepo.createLicense as any).mockClear(); (LicenseRepo.updateLicense as any).mockClear(); - (LicenseRepo.updateLicenseByStripePaymentId as any).mockClear(); (LicenseRepo.getFreeTrialLicenseByUserId as any).mockClear(); (LicenseRepo.getExistingSubscriptionLicenseByUserId as any).mockClear(); process.env.STRIPE_SANDBOX = 'true' @@ -109,6 +107,7 @@ describe('WebhookService', () => { status: 'active', license_type: 'subscription', stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr', + expiration_date: null, updated_at: expect.any(Date) }) @@ -131,6 +130,7 @@ describe('WebhookService', () => { expect(LicenseRepo.updateLicense).toHaveBeenCalledWith('expired-license-456', { status: 'active', stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr', + expiration_date: null, updated_at: expect.any(Date) }) diff --git a/tests/setup.ts b/tests/setup.ts index 2c22790..4b2cd30 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,13 +1,14 @@ // Test environment setup import { afterAll, beforeAll } from 'bun:test' -import { startTestDatabase, stopTestDatabase } from './helpers/testDatabase' +import { startTestDatabase, resetTestDatabase } from './helpers/testDatabase' import { startTestServer, stopTestServer } from './helpers/testServer' +import { stopDb } from '../config/database' beforeAll(async () => { // Set test environment variables process.env.NODE_ENV = 'test' process.env.PORT = '3002' - + // Suppress console logs during tests for cleaner output // You can set SUPPRESS_TEST_LOGS=false to see logs if needed for debugging console.log('๐Ÿงช Test environment initialized') @@ -19,8 +20,30 @@ beforeAll(async () => { afterAll(async () => { console.log('๐Ÿงช Test environment stopped') - await stopTestDatabase() + // Only reset data between test files, keep connection alive + await resetTestDatabase() await stopTestServer() }) +// Ensure cleanup happens when test run completes +const cleanup = async () => { + console.log('๐Ÿงน Cleaning up database connection...') + stopDb() +} + +// Register cleanup handlers for process exit +process.on('exit', () => { + cleanup() +}) + +process.on('SIGINT', async () => { + await cleanup() + process.exit(0) +}) + +process.on('SIGTERM', async () => { + await cleanup() + process.exit(0) +}) + export {} diff --git a/tests/unit/discordProvider.test.ts b/tests/unit/discordProvider.test.ts index a2dd1c4..5057cc6 100644 --- a/tests/unit/discordProvider.test.ts +++ b/tests/unit/discordProvider.test.ts @@ -229,7 +229,7 @@ describe('DiscordNotificationProvider', () => { expect(callArgs).toBeDefined() const body = JSON.parse(callArgs![1].body) - expect(body.embeds[0].title).toBe('๐Ÿ“Š Weekly Report') + expect(body.embeds[0].title).toBe('๐Ÿ“Š Weekly Report Reminder') expect(body.embeds[0].color).toBe(0x9932CC) // Purple }) }) diff --git a/tests/unit/loopsProvider.test.ts b/tests/unit/loopsProvider.test.ts new file mode 100644 index 0000000..ad59de4 --- /dev/null +++ b/tests/unit/loopsProvider.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' +import { LoopsNotificationProvider } from '../../services/providers/LoopsNotificationProvider' +import type { NotificationPayload, BaseUserRecord } from '../../types/notifications' + +// Store original fetch +const originalFetch = global.fetch +// Create a mock that maintains both fetch interface and mock methods +const mockFetch = mock() as unknown as typeof fetch & { + mockClear(): void + mockResolvedValueOnce(value: any): void + mockRejectedValueOnce(value: any): void + mock: { calls: any[] } +} + +describe('LoopsNotificationProvider', () => { + const mockApiKey = 'test-loops-api-key' + const mockTemplates = { + friend_request_existing_user: 'template_existing_123', + friend_request_new_user: 'template_new_456', + weekly_report: 'template_weekly_789' + } + let provider: LoopsNotificationProvider + + beforeEach(() => { + global.fetch = mockFetch + provider = new LoopsNotificationProvider(mockApiKey, mockTemplates) + mockFetch.mockClear() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + describe('constructor', () => { + it('should throw error if API key is not provided', () => { + expect(() => new LoopsNotificationProvider('')).toThrow('Loops API key is required') + expect(() => new LoopsNotificationProvider(undefined as unknown as string)).toThrow('Loops API key is required') + }) + + it('should create provider with valid API key', () => { + expect(provider.name).toBe('email') + }) + + it('should work without templates parameter', () => { + const basicProvider = new LoopsNotificationProvider(mockApiKey) + expect(basicProvider.name).toBe('email') + }) + }) + + describe('send - friend request notifications', () => { + const friendRequestPayload: NotificationPayload = { + type: 'friend_request', + user: { + id: 'request-123', + email: 'recipient@example.com' + } as BaseUserRecord, + referenceId: 'friend_request_123', + data: { + fromEmail: 'sender@example.com', + requestId: 'request-123', + existingUser: true + } + } + + it('should send friend request notification successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: true }) + }) + + const result = await provider.send(friendRequestPayload) + + expect(result.success).toBe(true) + expect(result.userId).toBe('request-123') + expect(result.referenceId).toBe('friend_request_123') + expect(result.channel).toBe('email') + expect(result.message).toContain('Email notification sent successfully via Loops') + expect(result.notificationId).toMatch(/loops_\d+_request-123/) + expect(result.timestamp).toBeInstanceOf(Date) + }) + + it('should use existing user template for existing users', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: true }) + }) + + await provider.send(friendRequestPayload) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://app.loops.so/api/v1/transactional', + expect.objectContaining({ + method: 'POST', + headers: { + 'Authorization': `Bearer ${mockApiKey}`, + 'Content-Type': 'application/json' + } + }) + ) + + const callArgs = mockFetch.mock.calls?.[0] + expect(callArgs).toBeDefined() + const body = JSON.parse(callArgs![1].body) + + expect(body.transactionalId).toBe('template_existing_123') + expect(body.email).toBe('recipient@example.com') + expect(body.dataVariables).toEqual({ + to_email: 'recipient@example.com', + from_email: 'sender@example.com', + request_id: 'request-123' + }) + }) + + it('should use new user template for new users', async () => { + const newUserPayload = { + ...friendRequestPayload, + data: { + ...friendRequestPayload.data, + existingUser: false + } + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: true }) + }) + + await provider.send(newUserPayload) + + const callArgs = mockFetch.mock.calls?.[0] + const body = JSON.parse(callArgs![1].body) + + expect(body.transactionalId).toBe('template_new_456') + }) + + it('should use default template IDs when not configured', async () => { + const basicProvider = new LoopsNotificationProvider(mockApiKey, {}) + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: true }) + }) + + await basicProvider.send(friendRequestPayload) + + const callArgs = mockFetch.mock.calls?.[0] + const body = JSON.parse(callArgs![1].body) + + expect(body.transactionalId).toBe('cmc3u8e020700z00iason0m0f') + }) + + it('should handle Loops API errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => 'Bad Request' + }) + + const result = await provider.send(friendRequestPayload) + + expect(result.success).toBe(false) + expect(result.error).toContain('Loops API error: 400 - Bad Request') + expect(result.userId).toBe('request-123') + expect(result.referenceId).toBe('friend_request_123') + }) + + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const result = await provider.send(friendRequestPayload) + + expect(result.success).toBe(false) + expect(result.error).toBe('Network error') + expect(result.userId).toBe('request-123') + }) + }) + + describe('send - weekly report notifications', () => { + const weeklyReportPayload: NotificationPayload = { + type: 'weekly_report', + user: { + id: 'system', + email: 'team@codeclimbers.io' + } as BaseUserRecord, + referenceId: 'weekly_2024_W03', + data: { + timestamp: '2024-01-15T09:00:00Z', + totalUsers: 150, + activeUsers: 120 + } + } + + it('should send weekly report notification successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: true }) + }) + + const result = await provider.send(weeklyReportPayload) + + expect(result.success).toBe(true) + expect(result.userId).toBe('system') + expect(result.channel).toBe('email') + }) + + it('should format weekly report email correctly', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: true }) + }) + + await provider.send(weeklyReportPayload) + + const callArgs = mockFetch.mock.calls?.[0] + expect(callArgs).toBeDefined() + const body = JSON.parse(callArgs![1].body) + + expect(body.transactionalId).toBe('template_weekly_789') + expect(body.email).toBe('team@codeclimbers.io') + expect(body.dataVariables).toEqual({ + timestamp: '2024-01-15T09:00:00Z', + totalUsers: 150, + activeUsers: 120 + }) + }) + + it('should return error when weekly report template is not configured', async () => { + const basicProvider = new LoopsNotificationProvider(mockApiKey, {}) + + const result = await basicProvider.send(weeklyReportPayload) + + expect(result.success).toBe(false) + expect(result.error).toBe('No template configured') + expect(result.message).toContain('No email template configured for weekly_report') + }) + }) + + describe('send - unsupported notification types', () => { + const unsupportedPayload: NotificationPayload = { + type: 'paid_user', + user: { + id: 'user-123', + email: 'test@example.com' + } as BaseUserRecord, + referenceId: 'paid_user_123', + data: {} + } + + it('should return error for unsupported notification types', async () => { + const result = await provider.send(unsupportedPayload) + + expect(result.success).toBe(false) + expect(result.error).toBe('No template configured') + expect(result.message).toContain('No email template configured for paid_user') + expect(mockFetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/types/jobs.ts b/types/jobs.ts index cfd0da8..9f05bf2 100644 --- a/types/jobs.ts +++ b/types/jobs.ts @@ -35,6 +35,7 @@ export interface UserMetrics { totalUsers: number } + // Job result interface export interface JobResult { success: boolean @@ -98,6 +99,7 @@ export const JOB_TYPES = { SLACK_CLEANUP_DND: 'slack-cleanup-dnd', SLACK_CLEANUP_STATUS: 'slack-cleanup-status', WEEKLY_EMAIL_REMINDER: 'weekly-email-reminder', + WEEKLY_REPORT: 'weekly-report', } as const export type JobType = typeof JOB_TYPES[keyof typeof JOB_TYPES] diff --git a/types/notifications.ts b/types/notifications.ts index 6e7dd7b..9c2f411 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -2,7 +2,7 @@ import type { PaidUserRecord, NewUserRecord, InactiveUserRecord } from './jobs' export type { PaidUserRecord, NewUserRecord, InactiveUserRecord } from './jobs' -export type NotificationType = 'paid_user' | 'new_user' | 'inactive_user' | 'weekly_report' | 'payment_failed' | 'checkout_completed' | 'subscription_cancelled' | 'subscription_expired' +export type NotificationType = 'paid_user' | 'new_user' | 'inactive_user' | 'weekly_report' | 'payment_failed' | 'checkout_completed' | 'subscription_cancelled' | 'subscription_expired' | 'friend_request' export type NotificationChannel = 'discord' | 'email' | 'slack' | 'sms' export interface BaseUserRecord { @@ -55,7 +55,9 @@ export interface NotificationConfig { } email: { enabled: boolean - provider?: 'sendgrid' | 'ses' + provider?: 'loops' + apiKey?: string + templates?: LoopsTemplateConfig } slack: { enabled: boolean @@ -97,4 +99,14 @@ export interface DiscordEmbedField { name: string value: string inline?: boolean +} + +export interface LoopsEmailPayload { + transactionalId: string + email: string + dataVariables: Record +} + +export interface LoopsTemplateConfig { + [key: string]: string } \ No newline at end of file