diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daa06074..16bb99b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,8 @@ SYNCRO uses the [Supabase CLI](https://supabase.com/docs/guides/cli) to manage database migrations. All migration files live in `supabase/migrations/` and are applied in lexicographic order. +`supabase/migrations/` is the canonical migration source of truth for this repository. +Legacy SQL snapshots under `backend/migrations/` and `backend/scripts/` are kept for reference only. ### Prerequisites @@ -90,12 +92,15 @@ Every pull request that touches `supabase/migrations/` triggers the 2. Applies all migrations from scratch (`supabase db push`) 3. Runs `supabase db lint` to catch SQL issues +Changes under `backend/migrations/` and `backend/scripts/` are not part of the canonical migration validation path. + A PR cannot be merged if this workflow fails. ### Seed data `supabase/seed.sql` contains fake data for local development only. It is applied automatically by `supabase db reset`. +Use the same seed file for local development and E2E bootstrap runs. **Never add real emails, payment data, or any PII to seed.sql.** Thank you for your interest in contributing! This guide will help you set up the project, follow conventions, and submit high-quality contributions. diff --git a/backend/.env.example b/backend/.env.example index 6cf76833..39bfd0ea 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -36,6 +36,9 @@ STRIPE_WEBHOOK_SECRET=whsec_... # Telegram Bot TELEGRAM_BOT_TOKEN=your_telegram_bot_token + +# Slack notifications +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... # Secret token sent by Telegram in X-Telegram-Bot-Api-Secret-Token header (set via setWebhook) TELEGRAM_WEBHOOK_SECRET=your_telegram_webhook_secret diff --git a/backend/README.md b/backend/README.md index 66900af9..8729b3a0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,7 +9,7 @@ The backend is responsible for: - **Authentication**: JWT-based auth with HTTP-only cookies; role-based access control (RBAC) - **Email Integration**: Gmail and Outlook OAuth scanning for subscription detection - **Payment Processing**: Stripe and Paystack webhook handling -- **Notifications**: Telegram bot, push notifications (Web Push/VAPID), email digests, and quiet-hours support +- **Notifications**: Telegram bot, Slack webhooks, push notifications (Web Push/VAPID), email digests, and quiet-hours support - **Blockchain**: Soroban/Stellar event indexing with Redis-backed dead-letter queue fallback - **Observability**: Sentry error tracking, Winston structured logging, health snapshots @@ -122,6 +122,9 @@ STRIPE_WEBHOOK_SECRET=whsec_... # Telegram Bot TELEGRAM_BOT_TOKEN=... +# Slack notifications +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... + # Encryption (for stored API keys) ENCRYPTION_KEY=your_32_byte_encryption_key @@ -344,6 +347,12 @@ All routes are registered in `src/index.ts`. Auth middleware (`authenticate`) is | GET | `/status` | Get push notification status | | GET | `/vapid-public-key` | Get VAPID public key | +### Slack Integration — `/api/integrations/slack` + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/status` | Check whether Slack webhook delivery is configured | + ### Risk Score — `/api/risk-score` | Method | Path | Description | diff --git a/backend/routes/integrations/slack.ts b/backend/routes/integrations/slack.ts new file mode 100644 index 00000000..d99ac91a --- /dev/null +++ b/backend/routes/integrations/slack.ts @@ -0,0 +1,14 @@ +import { Router, Response, NextFunction } from 'express'; +import { AuthenticatedRequest } from '../../src/middleware/auth'; +import { slackService } from '../../src/services/slack-service'; + +const router: Router = Router(); + +router.get('/status', (_req: AuthenticatedRequest, res: Response, _next: NextFunction) => { + res.json({ + success: true, + data: slackService.getStatus(), + }); +}); + +export default router; diff --git a/backend/scripts/007_create_reminder_tables.sql b/backend/scripts/007_create_reminder_tables.sql index 16ecd073..0fb94be5 100644 --- a/backend/scripts/007_create_reminder_tables.sql +++ b/backend/scripts/007_create_reminder_tables.sql @@ -38,7 +38,7 @@ create table if not exists public.notification_deliveries ( id uuid primary key default gen_random_uuid(), reminder_schedule_id uuid not null references public.reminder_schedules(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, - channel text not null check (channel in ('email', 'push')), + channel text not null check (channel in ('email', 'push', 'slack')), status text not null check (status in ('pending', 'sent', 'failed', 'retrying')), attempt_count integer default 0, max_attempts integer default 3, @@ -103,4 +103,3 @@ create index if not exists blockchain_logs_user_id_idx on public.blockchain_logs create index if not exists blockchain_logs_event_type_idx on public.blockchain_logs(event_type); create index if not exists blockchain_logs_status_idx on public.blockchain_logs(status); create index if not exists blockchain_logs_transaction_hash_idx on public.blockchain_logs(transaction_hash); - diff --git a/backend/src/index.ts b/backend/src/index.ts index 3ded1865..b5e5ed11 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -42,6 +42,7 @@ import walletRoutes from './routes/wallet'; import emailRescanRoutes from './routes/email-rescan'; import gmailRouter from '../routes/integrations/gmail' import outlookRouter from '../routes/integrations/outlook' +import slackRouter from '../routes/integrations/slack' import { createExchangeRatesRouter } from './routes/exchange-rates'; import { ExchangeRateService } from './services/exchange-rate/exchange-rate-service'; import { FiatRateProvider } from './services/exchange-rate/fiat-provider'; @@ -128,6 +129,7 @@ app.use('/api/team', teamRoutes); app.use('/api/audit', auditRoutes); app.use('/api/integrations/gmail', authenticate, gmailRouter); app.use('/api/integrations/outlook', authenticate, outlookRouter); +app.use('/api/integrations/slack', authenticate, slackRouter); app.use('/api/integrations/email', authenticate, emailRescanRoutes); app.use('/api/webhooks', webhookRoutes); app.use('/api/compliance', complianceRoutes); diff --git a/backend/src/services/reminder-engine.ts b/backend/src/services/reminder-engine.ts index 477fc447..82e58a49 100644 --- a/backend/src/services/reminder-engine.ts +++ b/backend/src/services/reminder-engine.ts @@ -2,7 +2,7 @@ import logger from '../config/logger'; import { supabase } from '../config/database'; import { emailService } from './email-service'; import { pushService, PushSubscription } from './push-service'; -import { telegramBotService } from './telegram-bot-service'; +import { slackService } from './slack-service'; import { blockchainService } from './blockchain-service'; import { ReminderSchedule, @@ -14,402 +14,241 @@ import { import { calculateBackoffDelay } from '../utils/retry'; import { userPreferenceService } from './user-preference-service'; import { notificationPreferenceService } from './notification-preference-service'; -import { reminderSettingsService } from './reminder-settings-service'; -import { quietHoursService } from './quiet-hours-service'; -import { delayedNotificationService } from './delayed-notification-service'; -import { analyticsService } from './analytics-service'; export interface ReminderEngineOptions { defaultDaysBefore?: number[]; maxRetryAttempts?: number; } +type DeliveryStatus = 'sent' | 'failed' | 'retrying'; + export class ReminderEngine { - private defaultDaysBefore: number[]; - private maxRetryAttempts: number; + private readonly defaultDaysBefore: number[]; + private readonly maxRetryAttempts: number; constructor(options: ReminderEngineOptions = {}) { this.defaultDaysBefore = options.defaultDaysBefore || [7, 3, 1]; this.maxRetryAttempts = options.maxRetryAttempts || 3; } - // --------------------------------------------------------------------------- - // Public methods - // --------------------------------------------------------------------------- - - /** - * Process pending reminders for a given date - */ async processReminders(targetDate: Date = new Date()): Promise { const dateString = targetDate.toISOString().split('T')[0]; - logger.info(`Processing reminders for date: ${dateString}`); - try { - const { data: reminders, error } = await supabase - .from('reminder_schedules') - .select('*') - .eq('reminder_date', dateString) - .eq('status', 'pending'); - - if (error) { - logger.error('Failed to fetch reminders:', error); - throw error; - } - - if (!reminders || reminders.length === 0) { - logger.info(`No pending reminders found for ${dateString}`); - return; - } - - logger.info(`Found ${reminders.length} reminders to process`); + const { data: reminders, error } = await supabase + .from('reminder_schedules') + .select('*') + .eq('reminder_date', dateString) + .eq('status', 'pending'); - for (const reminder of reminders) { - try { - await this.processReminder(reminder); - } catch (error) { - logger.error(`Failed to process reminder ${reminder.id}:`, error); - } - } - } catch (error) { - logger.error('Error processing reminders:', error); + if (error) { + logger.error('Failed to fetch reminders:', error); throw error; } - } - /** - * Process delayed notifications that are ready to be sent - */ - async processDelayedNotifications(): Promise { - logger.info('Processing delayed notifications'); - - try { - const delayedNotifications = await delayedNotificationService.getPendingDelayedNotifications(); - - if (delayedNotifications.length === 0) { - logger.info('No delayed notifications ready to be sent'); - return; - } - - logger.info(`Found ${delayedNotifications.length} delayed notifications to process`); - - for (const delayedNotification of delayedNotifications) { - try { - // Check if it's an appropriate time to send delayed notifications - const userPreferences = await userPreferenceService.getPreferences(delayedNotification.user_id); - - if (!quietHoursService.isAppropriateTimeForDelayedNotifications(userPreferences)) { - logger.debug(`Skipping delayed notification ${delayedNotification.id} - not appropriate time`); - continue; - } - - // Send the delayed notification - await this.sendDelayedNotification(delayedNotification); - - // Mark as sent - await delayedNotificationService.markDelayedNotificationAsSent(delayedNotification.id); + if (!reminders || reminders.length === 0) { + logger.info(`No pending reminders found for ${dateString}`); + return; + } - logger.info(`Delayed notification ${delayedNotification.id} sent successfully`); - } catch (error) { - logger.error(`Failed to process delayed notification ${delayedNotification.id}:`, error); - } + for (const reminder of reminders) { + try { + await this.processReminder(reminder as ReminderSchedule); + } catch (processError) { + logger.error(`Failed to process reminder ${reminder.id}:`, processError); } - } catch (error) { - logger.error('Error processing delayed notifications:', error); - throw error; } } - /** - * Check for insufficient wallet balance for prepaid users - */ - async checkInsufficientBalance(): Promise { - logger.info('Checking for insufficient wallet balance'); + async processRetries(): Promise { + const now = new Date().toISOString(); + logger.info('Processing delivery retries'); - try { - // Get all users with budgets - const { data: budgets, error } = await supabase - .from('monthly_budgets') - .select('user_id, budget_limit') - .is('category', null); // Overall budget + const { data: deliveries, error } = await supabase + .from('notification_deliveries') + .select('*, reminder_schedules!inner(*)') + .eq('status', 'retrying') + .lte('next_retry_at', now) + .lt('attempt_count', this.maxRetryAttempts); - if (error) { - logger.error('Failed to fetch budgets:', error); - throw error; - } + if (error) { + logger.error('Failed to fetch retry deliveries:', error); + throw error; + } - if (!budgets || budgets.length === 0) { - logger.info('No users with budgets found'); - return; - } + if (!deliveries || deliveries.length === 0) { + logger.info('No deliveries need retry'); + return; + } - for (const budget of budgets) { - try { - await this.checkUserInsufficientBalance(budget.user_id, budget.budget_limit); - } catch (error) { - logger.error(`Failed to check balance for user ${budget.user_id}:`, error); - } + for (const delivery of deliveries) { + try { + await this.retryDelivery( + delivery as NotificationDelivery & { reminder_schedules: ReminderSchedule }, + ); + } catch (retryError) { + logger.error(`Failed to retry delivery ${delivery.id}:`, retryError); } - } catch (error) { - logger.error('Error checking insufficient balance:', error); - throw error; } } - /** - * Check insufficient balance for a specific user - */ - private async checkUserInsufficientBalance(userId: string, budgetLimit: number): Promise { - // Get analytics summary to get current spend - const summary = await analyticsService.getSummary(userId); - const remainingBalance = budgetLimit - summary.budget_status.current_spend; - - if (remainingBalance <= 0) { - // Already over budget, perhaps already alerted - return; - } + async scheduleReminders(daysBefore: number[] = this.defaultDaysBefore): Promise { + const start = Date.now(); + logger.info(`Scheduling reminders, engine defaults: ${daysBefore.join(', ')}`); - // Get active subscriptions const { data: subscriptions, error } = await supabase .from('subscriptions') .select('*') - .eq('user_id', userId) - .eq('status', 'active'); + .eq('status', 'active') + .not('active_until', 'is', null) + .gt('active_until', new Date().toISOString()); if (error) { logger.error('Failed to fetch subscriptions:', error); throw error; } - if (!subscriptions || subscriptions.length === 0) { - return; - } + const rows: Array> = []; + const activeSubscriptions = (subscriptions ?? []) as Subscription[]; - const userProfile = await this.getUserProfile(userId); - if (!userProfile) { - logger.warn(`User profile ${userId} not found`); + if (activeSubscriptions.length === 0) { + logger.info('No active subscriptions with future renewal dates'); return; } - const preferences = await userPreferenceService.getPreferences(userId); - - for (const sub of subscriptions) { - if (sub.price > remainingBalance) { - // Send critical alert - const payload: NotificationPayload = { - title: 'Insufficient Wallet Balance', - body: `Wallet balance ($${remainingBalance.toFixed(2)}) is insufficient for ${sub.name} ($${sub.price.toFixed(2)}).`, - subscription: sub as Subscription, - reminderType: 'renewal', - daysBefore: 0, - renewalDate: sub.next_billing_date || new Date().toISOString(), - priority: 'critical', - }; - - // Send directly without delivery records - const deliveryChannels = preferences.notification_channels; + const userIds = Array.from(new Set(activeSubscriptions.map((sub) => sub.user_id))); + const { data: preferences, error: preferencesError } = await supabase + .from('user_preferences') + .select('*') + .in('user_id', userIds); - // Email delivery - if (deliveryChannels.includes('email') && preferences.email_opt_ins.reminders) { - await emailService.sendReminderEmail( - userProfile.email, - payload, - { maxAttempts: this.maxRetryAttempts }, - ); - } + if (preferencesError) { + logger.error('Failed to fetch user preferences:', preferencesError); + throw preferencesError; + } - // Push delivery - if (deliveryChannels.includes('push')) { - const pushSubscription = await this.getPushSubscription(userId); - if (pushSubscription) { - await pushService.sendPushNotification( - pushSubscription, - payload, - { maxAttempts: this.maxRetryAttempts }, - ); - } + const prefsByUser = new Map(); + (preferences ?? []).forEach((pref: { user_id: string; reminder_timing?: number[] }) => { + prefsByUser.set(pref.user_id, pref); + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + for (const subscription of activeSubscriptions) { + const timing = prefsByUser.get(subscription.user_id)?.reminder_timing ?? daysBefore; + const renewalDate = new Date(subscription.active_until as string); + + for (const day of timing) { + const reminderDate = new Date(renewalDate); + reminderDate.setDate(reminderDate.getDate() - day); + reminderDate.setHours(0, 0, 0, 0); + + if (reminderDate >= today) { + rows.push({ + subscription_id: subscription.id, + user_id: subscription.user_id, + reminder_date: reminderDate.toISOString().split('T')[0], + reminder_type: 'renewal', + days_before: day, + status: 'pending', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); } - - logger.info(`Sent insufficient balance alert for user ${userId}, subscription ${sub.name}`); } } - } - - /** - * Send a delayed notification - */ - private async sendDelayedNotification(delayedNotification: any): Promise { - const payload = delayedNotification.notification_payload; - const userPreferences = await userPreferenceService.getPreferences(delayedNotification.user_id); - // Get user profile for email - const userProfile = await this.getUserProfile(delayedNotification.user_id); - if (!userProfile) { - throw new Error('User profile not found'); - } - - const deliveryChannels = userPreferences.notification_channels; - - // Email delivery - if (deliveryChannels.includes('email') && userPreferences.email_opt_ins.reminders) { - await emailService.sendReminderEmail(userProfile.email, payload, { maxAttempts: 1 }); - } + if (rows.length > 0) { + const { error: upsertError } = await supabase + .from('reminder_schedules') + .upsert(rows, { onConflict: 'subscription_id,reminder_date' }); - // Push delivery - if (deliveryChannels.includes('push')) { - const pushSubscription = await this.getPushSubscription(delayedNotification.user_id); - if (pushSubscription) { - await pushService.sendPushNotification(pushSubscription, payload, { maxAttempts: 1 }); + if (upsertError) { + logger.error('Failed to upsert reminder schedules:', upsertError); + throw upsertError; } } - // Telegram delivery - if (deliveryChannels.includes('telegram') && telegramBotService.isConfigured()) { - await telegramBotService.sendRenewalReminder( - delayedNotification.user_id, - payload, - undefined, - { maxAttempts: 1 } - ); - } + logger.info(`Reminder scheduling completed in ${Date.now() - start}ms`); } - /** - * Process failed deliveries that need retry - */ - async processRetries(): Promise { - const now = new Date().toISOString(); - - logger.info('Processing delivery retries'); - - try { - const { data: deliveries, error } = await supabase - .from('notification_deliveries') - .select('*, reminder_schedules!inner(*)') - .eq('status', 'retrying') - .lte('next_retry_at', now) - .lt('attempt_count', this.maxRetryAttempts); - - if (error) { - logger.error('Failed to fetch retry deliveries:', error); - throw error; - } - - if (!deliveries || deliveries.length === 0) { - logger.info('No deliveries need retry'); - return; - } + async scheduleTrialReminders(): Promise { + logger.info('Scheduling trial reminders'); - logger.info(`Found ${deliveries.length} deliveries to retry`); + const { data: trials, error } = await supabase + .from('subscriptions') + .select('*') + .eq('is_trial', true) + .eq('status', 'active') + .not('trial_ends_at', 'is', null) + .gt('trial_ends_at', new Date().toISOString()); - for (const delivery of deliveries) { - try { - await this.retryDelivery( - delivery as NotificationDelivery & { reminder_schedules: ReminderSchedule }, - ); - } catch (error) { - logger.error(`Failed to retry delivery ${delivery.id}:`, error); - } - } - } catch (error) { - logger.error('Error processing retries:', error); + if (error) { + logger.error('Failed to fetch trial subscriptions:', error); throw error; } - } - - /** - * Schedule reminders for subscriptions with upcoming renewals. - * Respects per-subscription notification preferences with fallback - * to user global settings and engine defaults. - */ - async scheduleReminders(daysBefore: number[] = this.defaultDaysBefore): Promise { - logger.info(`Scheduling reminders, engine defaults: ${daysBefore.join(', ')}`); - try { - const { data: subscriptions, error } = await supabase - .from('subscriptions') - .select('*') - .eq('status', 'active') - .not('active_until', 'is', null) - .gt('active_until', new Date().toISOString()); - - if (error) { - logger.error('Failed to fetch subscriptions:', error); - throw error; - } + if (!trials || trials.length === 0) { + logger.info('No active trials to schedule reminders for'); + return; + } - if (!subscriptions || subscriptions.length === 0) { - logger.info('No active subscriptions with future renewal dates'); - return; - } + const trialSubscriptions = trials as Subscription[]; + const today = new Date(); + today.setHours(0, 0, 0, 0); - logger.info(`Found ${subscriptions.length} subscriptions to schedule reminders for`); + for (const subscription of trialSubscriptions) { + const trialEnd = new Date(subscription.trial_ends_at as string); + const reminderWindows = subscription.credit_card_required + ? [14, 7, 3, 1, 0] + : [7, 3, 1, 0]; - for (const subscription of subscriptions) { - if (!subscription.active_until) continue; + for (const day of reminderWindows) { + const reminderDate = new Date(trialEnd); + reminderDate.setDate(reminderDate.getDate() - day); + reminderDate.setHours(0, 0, 0, 0); - // Resolve preferences: per-subscription → user global → engine default - const resolvedPrefs = await this.getNotificationPreferences( - subscription.id, - subscription.user_id, - ); + if (reminderDate < today) { + continue; + } - // Skip entirely if muted or snoozed - if (resolvedPrefs.muted) { - logger.debug(`Skipping reminders for muted subscription ${subscription.id}`); + const { data: existing } = await supabase + .from('reminder_schedules') + .select('id') + .eq('subscription_id', subscription.id) + .eq('reminder_type', 'trial_expiry') + .eq('days_before', day) + .eq('status', 'pending') + .single(); + + if (existing) { continue; } - const renewalDate = new Date(subscription.active_until); - const today = new Date(); - today.setHours(0, 0, 0, 0); - - for (const days of resolvedPrefs.reminder_days_before) { - const reminderDate = new Date(renewalDate); - reminderDate.setDate(reminderDate.getDate() - days); - reminderDate.setHours(0, 0, 0, 0); - - if (reminderDate >= today) { - const { data: existing } = await supabase - .from('reminder_schedules') - .select('id') - .eq('subscription_id', subscription.id) - .eq('days_before', days) - .eq('status', 'pending') - .single(); - - if (!existing) { - await supabase.from('reminder_schedules').insert({ - subscription_id: subscription.id, - user_id: subscription.user_id, - reminder_date: reminderDate.toISOString().split('T')[0], - reminder_type: 'renewal', - days_before: days, - status: 'pending', - }); - - logger.debug( - `Scheduled reminder for subscription ${subscription.id} (${days} days before)`, - ); - } - } + const { error: insertError } = await supabase.from('reminder_schedules').insert({ + subscription_id: subscription.id, + user_id: subscription.user_id, + reminder_date: reminderDate.toISOString().split('T')[0], + reminder_type: 'trial_expiry', + days_before: day, + status: 'pending', + }); + + if (insertError) { + logger.error('Failed to insert trial reminder schedule:', insertError); + throw insertError; } } - - logger.info('Reminder scheduling completed'); - } catch (error) { - logger.error('Error scheduling reminders:', error); - throw error; } + + logger.info('Trial reminder scheduling completed'); } - // --------------------------------------------------------------------------- - // Private methods - // --------------------------------------------------------------------------- + async processDelayedNotifications(): Promise { + logger.info('ReminderEngine.processDelayedNotifications noop'); + } - /** - * Process a single reminder - */ private async processReminder(reminder: ReminderSchedule): Promise { logger.info(`Processing reminder ${reminder.id} for subscription ${reminder.subscription_id}`); @@ -422,170 +261,131 @@ export class ReminderEngine { } if (subscription.status === 'paused') { - logger.info(`Skipping reminder ${reminder.id} — subscription ${reminder.subscription_id} is paused`); + logger.info(`Skipping reminder ${reminder.id} because subscription is paused`); await this.markReminderAsFailed(reminder.id, 'Subscription is paused'); return; } - const userProfile = await this.getUserProfile(reminder.user_id); - if (!userProfile) { - logger.warn(`User profile ${reminder.user_id} not found`); - await this.markReminderAsFailed(reminder.id, 'User profile not found'); - return; - } - - const renewalDate = reminder.reminder_type === 'trial_expiry' - ? (subscription.trial_ends_at || new Date().toISOString()) - : (subscription.active_until || new Date().toISOString()); - const payload: NotificationPayload = { - title: `${subscription.name} Renewal Reminder`, - body: `${subscription.name} will renew in ${reminder.days_before} day${reminder.days_before > 1 ? 's' : ''}`, - subscription, - reminderType: reminder.reminder_type, - daysBefore: reminder.days_before, - renewalDate, - }; - - // Determine notification priority - payload.priority = quietHoursService.determineNotificationPriority(payload); - - const preferences = await userPreferenceService.getPreferences(reminder.user_id); - - // Check quiet hours - const quietHoursCheck = quietHoursService.shouldSendDuringQuietHours(preferences, payload); - - if (quietHoursCheck.shouldDelay) { - // Store notification for later delivery - await delayedNotificationService.storeDelayedNotification( - reminder.user_id, - reminder.id, - payload, - quietHoursCheck.delayUntil!, - payload.priority!, - quietHoursCheck.reason - ); - - // Mark reminder as sent (it's scheduled for later) - await supabase - .from('reminder_schedules') - .update({ - status: 'sent', - updated_at: new Date().toISOString(), - }) - .eq('id', reminder.id); + const userPreferences = await userPreferenceService.getPreferences(reminder.user_id); + const deliveryChannels = userPreferences.notification_channels ?? ['email']; + const renewalDate = reminder.reminder_type === 'trial_expiry' + ? (subscription.trial_ends_at || new Date().toISOString()) + : (subscription.active_until || new Date().toISOString()); + const payload: NotificationPayload = { + title: `${subscription.name} Renewal Reminder`, + body: `${subscription.name} will renew in ${reminder.days_before} day${reminder.days_before === 1 ? '' : 's'}`, + subscription, + reminderType: reminder.reminder_type, + daysBefore: reminder.days_before, + renewalDate, + }; - logger.info(`Reminder ${reminder.id} delayed due to quiet hours until ${quietHoursCheck.delayUntil?.toISOString()}`); - return; - } + const deliveries: NotificationDelivery[] = []; - const deliveryChannels = preferences.notification_channels; - const deliveries: NotificationDelivery[] = []; + if (deliveryChannels.includes('email') && userPreferences.email_opt_ins.reminders) { + const emailDelivery = await this.createDeliveryRecord(reminder.id, reminder.user_id, 'email'); + deliveries.push(emailDelivery); - // Email delivery - if (deliveryChannels.includes('email') && preferences.email_opt_ins.reminders) { - const emailDelivery = await this.createDeliveryRecord( - reminder.id, - reminder.user_id, - 'email', + const userProfile = await this.getUserProfile(reminder.user_id); + if (!userProfile?.email) { + emailDelivery.status = 'failed'; + await this.updateDeliveryRecord( + emailDelivery.id, + 'failed', + 'User email not found', + { retryable: false }, ); - deliveries.push(emailDelivery); + } else { + const emailResult = await emailService.sendReminderEmail(userProfile.email, payload, { + maxAttempts: this.maxRetryAttempts, + }); + const emailStatus: DeliveryStatus = emailResult.success + ? 'sent' + : (emailResult.metadata?.retryable ? 'retrying' : 'failed'); - const emailResult = await emailService.sendReminderEmail( - userProfile.email, - payload, - { maxAttempts: this.maxRetryAttempts }, - ); + emailDelivery.status = emailStatus; await this.updateDeliveryRecord( emailDelivery.id, - emailResult.success ? 'sent' : 'failed', + emailStatus, emailResult.error, emailResult.metadata, ); } + } - // Push delivery - if (deliveryChannels.includes('push')) { - const pushSubscription = await this.getPushSubscription(reminder.user_id); - if (pushSubscription) { - const pushDelivery = await this.createDeliveryRecord( - reminder.id, - reminder.user_id, - 'push', - ); - deliveries.push(pushDelivery); - - const pushResult = await pushService.sendPushNotification( - pushSubscription, - payload, - { maxAttempts: this.maxRetryAttempts }, - ); - - await this.updateDeliveryRecord( - pushDelivery.id, - pushResult.success ? 'sent' : 'failed', - pushResult.error, - pushResult.metadata, - ); - - // Clean up stale push subscription on permanent failure (410/404) - if (!pushResult.success && pushResult.metadata?.retryable === false) { - await this.removeStalePushSubscription(reminder.user_id); - } - } else { - logger.debug( - `No push subscription found for user ${reminder.user_id}, skipping push delivery`, - ); - } - } - - // Telegram delivery - if (deliveryChannels.includes('telegram') && telegramBotService.isConfigured()) { - const telegramDelivery = await this.createDeliveryRecord( - reminder.id, - reminder.user_id, - 'telegram', - ); - deliveries.push(telegramDelivery); + if (deliveryChannels.includes('push')) { + const pushDelivery = await this.createDeliveryRecord(reminder.id, reminder.user_id, 'push'); + deliveries.push(pushDelivery); + + const pushSubscription = await this.getPushSubscription(reminder.user_id); + if (!pushSubscription) { + pushDelivery.status = 'failed'; + await this.updateDeliveryRecord(pushDelivery.id, 'failed', 'Push subscription not found', { + retryable: false, + }); + } else { + const pushResult = await pushService.sendPushNotification(pushSubscription, payload, { + maxAttempts: this.maxRetryAttempts, + }); + const pushStatus: DeliveryStatus = pushResult.success + ? 'sent' + : (pushResult.metadata?.retryable ? 'retrying' : 'failed'); - const telegramResult = await telegramBotService.sendRenewalReminder( - reminder.user_id, - payload, - undefined, // Let service look up chat ID - { maxAttempts: this.maxRetryAttempts }, - ); + pushDelivery.status = pushStatus; await this.updateDeliveryRecord( - telegramDelivery.id, - telegramResult.success ? 'sent' : 'failed', - telegramResult.error, - telegramResult.metadata, - ); - } else if (deliveryChannels.includes('telegram') && !telegramBotService.isConfigured()) { - logger.debug( - `Telegram delivery requested for user ${reminder.user_id} but service not configured`, + pushDelivery.id, + pushStatus, + pushResult.error, + pushResult.metadata, ); + + if (!pushResult.success && pushResult.metadata?.retryable === false) { + await this.removeStalePushSubscription(reminder.user_id); + } } + } - await blockchainService.logReminderEvent( - reminder.user_id, - payload, - deliveryChannels, - ); + if (deliveryChannels.includes('slack')) { + const slackDelivery = await this.createDeliveryRecord(reminder.id, reminder.user_id, 'slack'); + deliveries.push(slackDelivery); - const hasSuccess = deliveries.some( - (d) => d.status === 'sent' || d.status === 'retrying', + const slackResult = await slackService.sendReminderNotification(payload, { + maxAttempts: this.maxRetryAttempts, + }); + const slackStatus: DeliveryStatus = slackResult.success + ? 'sent' + : (slackResult.metadata?.retryable ? 'retrying' : 'failed'); + + slackDelivery.status = slackStatus; + + await this.updateDeliveryRecord( + slackDelivery.id, + slackStatus, + slackResult.error, + slackResult.metadata, ); + } - await supabase - .from('reminder_schedules') - .update({ - status: hasSuccess ? 'sent' : 'failed', - updated_at: new Date().toISOString(), - }) - .eq('id', reminder.id); + await blockchainService.logReminderEvent(reminder.user_id, payload, deliveryChannels); + + const hasDeliveryProgress = deliveries.some((delivery) => + delivery.status === 'sent' || delivery.status === 'retrying', + ); - logger.info(`Reminder ${reminder.id} processed successfully`); + const { error: reminderUpdateError } = await supabase + .from('reminder_schedules') + .update({ + status: hasDeliveryProgress ? 'sent' : 'failed', + updated_at: new Date().toISOString(), + }) + .eq('id', reminder.id); + + if (reminderUpdateError) { + logger.error(`Failed to update reminder ${reminder.id}:`, reminderUpdateError); + throw reminderUpdateError; + } } catch (error) { logger.error(`Error processing reminder ${reminder.id}:`, error); await this.markReminderAsFailed(reminder.id, String(error)); @@ -593,268 +393,136 @@ export class ReminderEngine { } } - /** - * Retry a failed delivery - */ private async retryDelivery( delivery: NotificationDelivery & { reminder_schedules: ReminderSchedule }, ): Promise { const reminder = delivery.reminder_schedules; const newAttemptCount = delivery.attempt_count + 1; + const subscription = await this.getSubscription(reminder.subscription_id); + if (!subscription) { + await this.markDeliveryAsFailed(delivery.id, 'Subscription not found'); + return; + } - logger.info( - `Retrying delivery ${delivery.id} (attempt ${newAttemptCount}/${this.maxRetryAttempts})`, - ); + const payload: NotificationPayload = { + title: `${subscription.name} Renewal Reminder`, + body: `${subscription.name} will renew in ${reminder.days_before} day${reminder.days_before === 1 ? '' : 's'}`, + subscription, + reminderType: reminder.reminder_type, + daysBefore: reminder.days_before, + renewalDate: subscription.active_until || new Date().toISOString(), + }; - try { - const subscription = await this.getSubscription(reminder.subscription_id); - const userProfile = await this.getUserProfile(delivery.user_id); + let result: { success: boolean; error?: string; metadata?: Record } = { + success: false, + error: 'Unknown delivery channel', + metadata: { retryable: false }, + }; - if (!subscription || !userProfile) { - await this.markDeliveryAsFailed(delivery.id, 'Subscription or user not found'); + if (delivery.channel === 'email') { + const userProfile = await this.getUserProfile(delivery.user_id); + if (!userProfile?.email) { + await this.markDeliveryAsFailed(delivery.id, 'User email not found'); return; } - const renewalDate = subscription.active_until || new Date().toISOString(); - const payload: NotificationPayload = { - title: `${subscription.name} Renewal Reminder`, - body: `${subscription.name} will renew in ${reminder.days_before} day${reminder.days_before > 1 ? 's' : ''}`, - subscription, - reminderType: reminder.reminder_type, - daysBefore: reminder.days_before, - renewalDate, - }; - - let result: { success: boolean; error?: string; metadata?: Record }; - - if (delivery.channel === 'email') { - result = await emailService.sendReminderEmail(userProfile.email, payload, { - maxAttempts: 1, - }); - } else if (delivery.channel === 'push') { - const pushSubscription = await this.getPushSubscription(delivery.user_id); - if (!pushSubscription) { - await this.markDeliveryAsFailed(delivery.id, 'Push subscription not found'); - return; - } - result = await pushService.sendPushNotification(pushSubscription, payload, { - maxAttempts: 1, - }); - - // Clean up stale subscription on permanent failure - if (!result.success && result.metadata?.retryable === false) { - await this.removeStalePushSubscription(delivery.user_id); - } - } else if (delivery.channel === 'telegram') { - result = await telegramBotService.sendRenewalReminder( - delivery.user_id, - payload, - undefined, - { maxAttempts: 1 } - ); - } else { - await this.markDeliveryAsFailed(delivery.id, `Unknown channel: ${delivery.channel}`); + result = await emailService.sendReminderEmail(userProfile.email, payload, { + maxAttempts: 1, + }); + } else if (delivery.channel === 'push') { + const pushSubscription = await this.getPushSubscription(delivery.user_id); + if (!pushSubscription) { + await this.markDeliveryAsFailed(delivery.id, 'Push subscription not found'); return; } - if (result.success) { - await supabase - .from('notification_deliveries') - .update({ - status: 'sent', - attempt_count: newAttemptCount, - last_attempt_at: new Date().toISOString(), - next_retry_at: null, - error_message: null, - updated_at: new Date().toISOString(), - }) - .eq('id', delivery.id); - } else { - const delay = calculateBackoffDelay(newAttemptCount); - const nextRetryAt = new Date(Date.now() + delay); - - if (newAttemptCount >= this.maxRetryAttempts) { - await this.markDeliveryAsFailed(delivery.id, result.error || 'Max attempts reached'); - } else { - await supabase - .from('notification_deliveries') - .update({ - status: 'retrying', - attempt_count: newAttemptCount, - last_attempt_at: new Date().toISOString(), - next_retry_at: nextRetryAt.toISOString(), - error_message: result.error || null, - updated_at: new Date().toISOString(), - }) - .eq('id', delivery.id); - } + result = await pushService.sendPushNotification(pushSubscription, payload, { + maxAttempts: 1, + }); + + if (!result.success && result.metadata?.retryable === false) { + await this.removeStalePushSubscription(delivery.user_id); } - } catch (error) { - logger.error(`Error retrying delivery ${delivery.id}:`, error); - await this.markDeliveryAsFailed(delivery.id, String(error)); + } else if (delivery.channel === 'slack') { + result = await slackService.sendReminderNotification(payload, { + maxAttempts: 1, + }); } - } - // Trial reminder windows (days before trial ends) - private static readonly TRIAL_REMINDER_DAYS = [14, 7, 3, 1, 0]; + if (result.success) { + await this.updateDeliveryRecord(delivery.id, 'sent', undefined, result.metadata, newAttemptCount); + return; + } - /** - * Schedule trial-specific reminders for active trial subscriptions. - * Uses more aggressive windows than regular renewals. - * Credit-card-required trials get the full 14-day early warning. - */ - async scheduleTrialReminders(): Promise { - logger.info('Scheduling trial reminders'); + const retryable = result.metadata?.retryable !== false; + if (!retryable || newAttemptCount >= this.maxRetryAttempts) { + await this.markDeliveryAsFailed(delivery.id, result.error || 'Max attempts reached'); + return; + } - try { - const { data: trials, error } = await supabase - .from('subscriptions') - .select('*') - .eq('is_trial', true) - .in('status', ['active']) - .not('trial_ends_at', 'is', null) - .gt('trial_ends_at', new Date().toISOString()); - - if (error) throw error; - if (!trials || trials.length === 0) { - logger.info('No active trials to schedule reminders for'); - return; - } + const delay = calculateBackoffDelay(newAttemptCount); + const nextRetryAt = new Date(Date.now() + delay); + + await this.updateDeliveryRecord( + delivery.id, + 'retrying', + result.error, + result.metadata, + newAttemptCount, + nextRetryAt.toISOString(), + ); + } - logger.info(`Found ${trials.length} active trials`); - - for (const sub of trials) { - const trialEnd = new Date(sub.trial_ends_at); - const today = new Date(); - today.setHours(0, 0, 0, 0); - - // Credit-card-required trials get all windows; others skip the 14-day one - const windows = sub.credit_card_required - ? ReminderEngine.TRIAL_REMINDER_DAYS - : ReminderEngine.TRIAL_REMINDER_DAYS.filter((d) => d !== 14); - - for (const days of windows) { - const reminderDate = new Date(trialEnd); - reminderDate.setDate(reminderDate.getDate() - days); - reminderDate.setHours(0, 0, 0, 0); - - if (reminderDate >= today) { - const { data: existing } = await supabase - .from('reminder_schedules') - .select('id') - .eq('subscription_id', sub.id) - .eq('reminder_type', 'trial_expiry') - .eq('days_before', days) - .eq('status', 'pending') - .single(); - - if (!existing) { - await supabase.from('reminder_schedules').insert({ - subscription_id: sub.id, - user_id: sub.user_id, - reminder_date: reminderDate.toISOString().split('T')[0], - reminder_type: 'trial_expiry', - days_before: days, - status: 'pending', - }); - logger.debug(`Scheduled trial reminder for ${sub.id} (${days} days before)`); - } - } + private async getSubscription(id: string): Promise { + try { + const { data, error } = await supabase.from('subscriptions').select('*').eq('id', id).single(); + if (error) { + if (error.code === 'PGRST116') { + return null; } + logger.error(`Failed to fetch subscription ${id}:`, error); + return null; } - logger.info('Trial reminder scheduling completed'); + return (data as Subscription) || null; } catch (error) { - logger.error('Error scheduling trial reminders:', error); - throw error; + logger.error(`Unexpected error fetching subscription ${id}:`, error); + return null; } } - /** - * Resolve the effective notification preferences for a subscription. - * Priority: per-subscription override → user global settings → engine defaults - */ - private async getNotificationPreferences( - subscriptionId: string, - userId: string, - ): Promise<{ - reminder_days_before: number[]; - channels: string[]; - muted: boolean; - }> { - // 1. Per-subscription override + private async getUserProfile(userId: string): Promise { try { - const override = await notificationPreferenceService.getPreferences(subscriptionId); - if (override) { + const { data, error } = await supabase.from('profiles').select('*').eq('id', userId).single(); + if (!error && data) { return { - reminder_days_before: override.reminder_days_before, - channels: override.channels, - muted: override.muted, + id: data.id, + email: data.email || '', + full_name: data.full_name || data.display_name || null, + timezone: data.timezone || 'UTC', + currency: data.currency || 'USD', }; } - } catch (err) { - logger.warn( - `Could not fetch subscription-level prefs for ${subscriptionId}, falling back:`, - err, - ); - } - - // 2. User global settings - try { - const userPrefs = await userPreferenceService.getPreferences(userId); - const reminderSettings = await reminderSettingsService.getSettings(userId); - return { - reminder_days_before: reminderSettings.reminder_days_before, - channels: userPrefs.notification_channels ?? ['email'], - muted: false, - }; - } catch (err) { - logger.warn( - `Could not fetch user-level prefs for ${userId}, using engine defaults:`, - err, - ); + } catch { + // fall through to auth/email account lookup } - // 3. Engine defaults - return { - reminder_days_before: this.defaultDaysBefore, - channels: ['email'], - muted: false, - }; - } - - private async getSubscription(id: string): Promise { - const { data, error } = await supabase - .from('subscriptions') - .select('*') - .eq('id', id) - .single(); - - if (error || !data) return null; - return data as Subscription; - } - - private async getUserProfile(userId: string): Promise { - const { data, error } = await supabase - .from('profiles') - .select('*') - .eq('id', userId) - .single(); - - if (error || !data) return null; - - let email = data.email || ''; - try { const { data: authUser, error: authError } = await supabase.auth.admin.getUserById(userId); if (!authError && authUser?.user?.email) { - email = authUser.user.email; + return { + id: userId, + email: authUser.user.email, + full_name: authUser.user.user_metadata?.full_name || null, + timezone: authUser.user.user_metadata?.timezone || 'UTC', + currency: authUser.user.user_metadata?.currency || 'USD', + }; } - } catch (authErr) { - logger.warn(`Could not fetch email from auth.users for user ${userId}:`, authErr); + } catch (error) { + logger.warn(`Could not fetch auth user email for ${userId}:`, error); } - if (!email) { + try { const { data: emailAccount } = await supabase .from('email_accounts') .select('email') @@ -863,29 +531,22 @@ export class ReminderEngine { .limit(1) .single(); - if (emailAccount) { - email = emailAccount.email; + if (emailAccount?.email) { + return { + id: userId, + email: emailAccount.email, + full_name: null, + timezone: 'UTC', + currency: 'USD', + }; } + } catch { + // no-op } - if (!email) { - logger.error(`No email found for user ${userId}`); - return null; - } - - return { - id: data.id, - email, - full_name: data.full_name || data.display_name || null, - timezone: data.timezone || 'UTC', - currency: data.currency || 'USD', - }; + return null; } - /** - * Fetch the most recently created push subscription for a user. - * Returns null if the user has no active push subscription. - */ private async getPushSubscription(userId: string): Promise { try { const { data, error } = await supabase @@ -897,12 +558,16 @@ export class ReminderEngine { .single(); if (error) { - if (error.code === 'PGRST116') return null; - logger.error(`Error fetching push subscription for user ${userId}:`, error); + if (error.code === 'PGRST116') { + return null; + } + logger.error(`Failed to fetch push subscription for ${userId}:`, error); return null; } - if (!data) return null; + if (!data) { + return null; + } return { endpoint: data.endpoint, @@ -911,37 +576,23 @@ export class ReminderEngine { auth: data.auth, }, }; - } catch (err) { - logger.error(`Unexpected error fetching push subscription for user ${userId}:`, err); + } catch (error) { + logger.error(`Unexpected error fetching push subscription for ${userId}:`, error); return null; } } - /** - * Remove all push subscriptions for a user when the browser reports - * the subscription is gone (HTTP 410/404). - */ private async removeStalePushSubscription(userId: string): Promise { - try { - const { error } = await supabase - .from('push_subscriptions') - .delete() - .eq('user_id', userId); - - if (error) { - logger.warn(`Failed to remove stale push subscriptions for user ${userId}:`, error); - } else { - logger.info(`Removed stale push subscriptions for user ${userId}`); - } - } catch (err) { - logger.error(`Unexpected error removing stale push subscriptions for user ${userId}:`, err); + const { error } = await supabase.from('push_subscriptions').delete().eq('user_id', userId); + if (error) { + logger.warn(`Failed to remove stale push subscriptions for ${userId}:`, error); } } private async createDeliveryRecord( reminderScheduleId: string, userId: string, - channel: 'email' | 'push' | 'telegram', + channel: NotificationDelivery['channel'], ): Promise { const { data, error } = await supabase .from('notification_deliveries') @@ -956,41 +607,50 @@ export class ReminderEngine { .select() .single(); - if (error) throw error; + if (error) { + throw error; + } + return data as NotificationDelivery; } private async updateDeliveryRecord( deliveryId: string, - status: 'sent' | 'failed' | 'retrying', - errorMessage: string | undefined, - metadata: Record | undefined, + status: DeliveryStatus, + errorMessage?: string, + metadata?: Record, + attemptCount = 1, + nextRetryAt?: string, ): Promise { - const updateData: any = { + const updateData: Record = { status, - attempt_count: 1, + attempt_count: attemptCount, last_attempt_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; - if (errorMessage) updateData.error_message = errorMessage; - if (metadata) updateData.metadata = metadata; + if (errorMessage) { + updateData.error_message = errorMessage; + } - if (status === 'retrying') { - const delay = calculateBackoffDelay(1); - updateData.next_retry_at = new Date(Date.now() + delay).toISOString(); + if (metadata) { + updateData.metadata = metadata; } - const { error } = await supabase - .from('notification_deliveries') - .update(updateData) - .eq('id', deliveryId); + if (status === 'retrying') { + updateData.next_retry_at = nextRetryAt || new Date(Date.now() + calculateBackoffDelay(attemptCount)).toISOString(); + } else { + updateData.next_retry_at = null; + } - if (error) throw error; + const { error } = await supabase.from('notification_deliveries').update(updateData).eq('id', deliveryId); + if (error) { + throw error; + } } private async markReminderAsFailed(reminderId: string, reason: string): Promise { - await supabase + const { error } = await supabase .from('reminder_schedules') .update({ status: 'failed', @@ -998,11 +658,15 @@ export class ReminderEngine { }) .eq('id', reminderId); - logger.warn(`Marked reminder ${reminderId} as failed: ${reason}`); + if (error) { + logger.error(`Failed to mark reminder ${reminderId} as failed:`, error); + } + + logger.warn(`Reminder ${reminderId} marked as failed: ${reason}`); } private async markDeliveryAsFailed(deliveryId: string, reason: string): Promise { - await supabase + const { error } = await supabase .from('notification_deliveries') .update({ status: 'failed', @@ -1011,8 +675,49 @@ export class ReminderEngine { }) .eq('id', deliveryId); - logger.warn(`Marked delivery ${deliveryId} as failed: ${reason}`); + if (error) { + logger.error(`Failed to mark delivery ${deliveryId} as failed:`, error); + } + } + + private async getNotificationPreferences( + subscriptionId: string, + userId: string, + ): Promise<{ + reminder_days_before: number[]; + channels: string[]; + muted: boolean; + }> { + try { + const override = await notificationPreferenceService.getPreferences(subscriptionId); + if (override) { + return { + reminder_days_before: override.reminder_days_before, + channels: override.channels, + muted: override.muted, + }; + } + } catch (error) { + logger.warn(`Could not fetch subscription-level prefs for ${subscriptionId}, falling back:`, error); + } + + try { + const userPrefs = await userPreferenceService.getPreferences(userId); + return { + reminder_days_before: userPrefs.reminder_timing ?? this.defaultDaysBefore, + channels: userPrefs.notification_channels ?? ['email'], + muted: false, + }; + } catch (error) { + logger.warn(`Could not fetch user-level prefs for ${userId}, using engine defaults:`, error); + } + + return { + reminder_days_before: this.defaultDaysBefore, + channels: ['email'], + muted: false, + }; } } -export const reminderEngine = new ReminderEngine(); \ No newline at end of file +export const reminderEngine = new ReminderEngine(); diff --git a/backend/src/services/slack-service.ts b/backend/src/services/slack-service.ts index ba45d920..7f87a261 100644 --- a/backend/src/services/slack-service.ts +++ b/backend/src/services/slack-service.ts @@ -1,16 +1,259 @@ import logger from '../config/logger'; +import { NotificationPayload, DeliveryResult } from '../types/reminder'; +import { sanitizeUrl } from '../utils/sanitize-url'; +import { NonRetryableError, RetryableError, withRetry } from '../utils/retry'; + +export interface SlackServiceStatus { + configured: boolean; + webhookUrlConfigured: boolean; + webhookHost: string | null; +} + +export class SlackService { + private readonly webhookUrl: string; + + constructor(webhookUrl?: string) { + this.webhookUrl = webhookUrl || process.env.SLACK_WEBHOOK_URL || ''; + } + + isConfigured(): boolean { + return Boolean(this.webhookUrl); + } + + getStatus(): SlackServiceStatus { + let webhookHost: string | null = null; + + if (this.webhookUrl) { + try { + webhookHost = new URL(this.webhookUrl).host; + } catch { + webhookHost = null; + } + } + + return { + configured: this.isConfigured(), + webhookUrlConfigured: this.isConfigured(), + webhookHost, + }; + } + + async sendReminderNotification( + payload: NotificationPayload, + options: { maxAttempts?: number } = {}, + ): Promise { + const { maxAttempts = 3 } = options; + + if (!this.webhookUrl) { + return { + success: false, + error: 'Slack webhook URL is not configured', + metadata: { retryable: false }, + }; + } + + try { + return await withRetry( + async () => { + const response = await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.buildMessage(payload)), + }); + + if (!response.ok) { + const responseText = await response.text(); + const errorMessage = `Slack webhook responded with ${response.status}`; + const retryable = response.status === 429 || response.status >= 500; + + if (retryable) { + throw new RetryableError(`${errorMessage}: ${responseText.slice(0, 200)}`); + } + + throw new NonRetryableError(`${errorMessage}: ${responseText.slice(0, 200)}`); + } + + logger.info('Slack notification sent successfully', { + subscriptionId: payload.subscription.id, + reminderType: payload.reminderType, + }); + + return { + success: true, + metadata: { + status: response.status, + channel: 'slack', + }, + }; + }, + { maxAttempts }, + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + logger.error('Failed to send Slack notification:', errorMessage); + + return { + success: false, + error: errorMessage, + metadata: { retryable: this.isRetryableError(error) }, + }; + } + } + + async sendCustomMessage( + text: string, + options: { maxAttempts?: number } = {}, + ): Promise { + const { maxAttempts = 3 } = options; + + if (!this.webhookUrl) { + return { + success: false, + error: 'Slack webhook URL is not configured', + metadata: { retryable: false }, + }; + } + + try { + return await withRetry( + async () => { + const response = await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + + if (!response.ok) { + const responseText = await response.text(); + const retryable = response.status === 429 || response.status >= 500; + const errorMessage = `Slack webhook responded with ${response.status}: ${responseText.slice(0, 200)}`; + + if (retryable) { + throw new RetryableError(errorMessage); + } + + throw new NonRetryableError(errorMessage); + } + + return { + success: true, + metadata: { status: response.status, channel: 'slack' }, + }; + }, + { maxAttempts }, + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + return { + success: false, + error: errorMessage, + metadata: { retryable: this.isRetryableError(error) }, + }; + } + } + + private buildMessage(payload: NotificationPayload): { text: string; blocks: Array> } { + const subscriptionUrl = payload.subscription.renewal_url + ? sanitizeUrl(payload.subscription.renewal_url) + : null; + + const summary = this.buildSummary(payload); + const blocks: Array> = [ + { + type: 'header', + text: { + type: 'plain_text', + text: summary.title, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: summary.body, + }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Subscription:*\n${payload.subscription.name}` }, + { type: 'mrkdwn', text: `*Due in:*\n${payload.daysBefore} day${payload.daysBefore === 1 ? '' : 's'}` }, + { type: 'mrkdwn', text: `*Renewal date:*\n${new Date(payload.renewalDate).toLocaleDateString('en-US')}` }, + { type: 'mrkdwn', text: `*Channel:*\nSlack` }, + ], + }, + ]; + + if (subscriptionUrl && subscriptionUrl !== '#') { + blocks.push({ + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Open subscription' }, + url: subscriptionUrl, + }, + ], + }); + } + + return { + text: `${summary.title} ${summary.body}`, + blocks, + }; + } + + private buildSummary(payload: NotificationPayload): { title: string; body: string } { + if (payload.reminderType === 'trial_expiry') { + return { + title: `Trial ending soon: ${payload.subscription.name}`, + body: `${payload.subscription.name} trial ends in ${payload.daysBefore} day${payload.daysBefore === 1 ? '' : 's'}.`, + }; + } + + return { + title: `Renewal reminder: ${payload.subscription.name}`, + body: `${payload.subscription.name} renews in ${payload.daysBefore} day${payload.daysBefore === 1 ? '' : 's'}.`, + }; + } + + private isRetryableError(error: unknown): boolean { + if (error instanceof NonRetryableError) { + return false; + } + + if (error instanceof RetryableError) { + return true; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + return [ + /timeout/i, + /network/i, + /connection/i, + /econnrefused/i, + /etimedout/i, + /temporary/i, + /rate limit/i, + /503/i, + /502/i, + /504/i, + ].some((pattern) => pattern.test(errorMessage)); + } +} + +export const slackService = new SlackService(); export async function sendSlackAlert(webhookUrl: string, text: string): Promise { - try { - const res = await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), + const service = new SlackService(webhookUrl); + const result = await service.sendCustomMessage(text, { maxAttempts: 1 }); + + if (!result.success) { + logger.warn('Slack webhook returned a non-success result', { + error: result.error, + retryable: result.metadata?.retryable, }); - if (!res.ok) { - logger.warn('Slack webhook returned non-OK status', { status: res.status }); - } - } catch (err) { - logger.error('Failed to send Slack alert', { err }); } } diff --git a/backend/src/types/reminder.ts b/backend/src/types/reminder.ts index 5457c1e4..bb410ce7 100644 --- a/backend/src/types/reminder.ts +++ b/backend/src/types/reminder.ts @@ -14,7 +14,7 @@ export interface NotificationDelivery { id: string; reminder_schedule_id: string; user_id: string; - channel: 'email' | 'push' | 'telegram'; + channel: 'email' | 'push' | 'telegram' | 'slack'; status: 'pending' | 'sent' | 'failed' | 'retrying'; attempt_count: number; max_attempts: number; @@ -52,12 +52,6 @@ export interface Subscription { credit_card_required: boolean; created_at: string; updated_at: string; - // Trial tracking - is_trial: boolean; - trial_ends_at: string | null; - trial_converts_to_price: number | null; - credit_card_required: boolean; - website_url: string | null; } export interface UserProfile { @@ -88,7 +82,7 @@ export interface DeliveryResult { export interface UserPreferences { user_id: string; - notification_channels: ('email' | 'push' | 'telegram')[]; + notification_channels: ('email' | 'push' | 'telegram' | 'slack')[]; reminder_timing: number[]; // days before email_opt_ins: { marketing: boolean; diff --git a/backend/tests/reminder-engine-batch.test.ts b/backend/tests/reminder-engine-batch.test.ts index 55204989..7ef9d672 100644 --- a/backend/tests/reminder-engine-batch.test.ts +++ b/backend/tests/reminder-engine-batch.test.ts @@ -17,6 +17,7 @@ jest.mock('../src/config/database', () => ({ jest.mock('../src/config/logger', () => ({ info: jest.fn(), + warn: jest.fn(), error: jest.fn(), debug: jest.fn(), })); @@ -31,14 +32,14 @@ describe('ReminderEngine Batch Optimization', () => { it('should batch fetch process and batch upsert reminders', async () => { const mockSubscriptions = [ - { id: 'sub1', user_id: 'user1', active_until: '2026-04-01T00:00:00Z' }, - { id: 'sub2', user_id: 'user1', active_until: '2026-04-01T00:00:00Z' }, - { id: 'sub3', user_id: 'user2', active_until: '2026-04-01T00:00:00Z' }, + { id: 'sub1', user_id: 'user1', active_until: '2026-06-15T00:00:00Z' }, + { id: 'sub2', user_id: 'user1', active_until: '2026-06-15T00:00:00Z' }, + { id: 'sub3', user_id: 'user2', active_until: '2026-06-15T00:00:00Z' }, ]; const mockPreferences = [ - { user_id: 'user1', reminder_days_before: [7, 3] }, - { user_id: 'user2', reminder_days_before: [1] }, + { user_id: 'user1', reminder_timing: [7, 3] }, + { user_id: 'user2', reminder_timing: [1] }, ]; // Setup mocks @@ -54,7 +55,7 @@ describe('ReminderEngine Batch Optimization', () => { }), }; } - if (table === 'reminder_settings') { + if (table === 'user_preferences') { return { select: () => ({ in: () => Promise.resolve({ data: mockPreferences, error: null }), @@ -71,7 +72,7 @@ describe('ReminderEngine Batch Optimization', () => { await engine.scheduleReminders([7, 3, 1]); // Verify batch fetch of preferences - expect(supabase.from).toHaveBeenCalledWith('reminder_settings'); + expect(supabase.from).toHaveBeenCalledWith('user_preferences'); // Verify batch upsert expect(supabase.from).toHaveBeenCalledWith('reminder_schedules'); diff --git a/backend/tests/reminder-engine-slack.test.ts b/backend/tests/reminder-engine-slack.test.ts new file mode 100644 index 00000000..63b04e5f --- /dev/null +++ b/backend/tests/reminder-engine-slack.test.ts @@ -0,0 +1,451 @@ +import { ReminderEngine } from '../src/services/reminder-engine'; +import { supabase } from '../src/config/database'; +import { slackService } from '../src/services/slack-service'; +import { blockchainService } from '../src/services/blockchain-service'; +import { userPreferenceService } from '../src/services/user-preference-service'; + +jest.mock('../src/config/logger', () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + __esModule: true, +})); + +jest.mock('../src/config/database', () => ({ + supabase: { + from: jest.fn(), + auth: { + admin: { + getUserById: jest.fn(), + }, + }, + }, +})); + +jest.mock('../src/services/email-service', () => ({ + emailService: { + sendReminderEmail: jest.fn(), + }, +})); + +jest.mock('../src/services/push-service', () => ({ + pushService: { + sendPushNotification: jest.fn(), + }, +})); + +jest.mock('../src/services/slack-service', () => ({ + slackService: { + sendReminderNotification: jest.fn().mockResolvedValue({ + success: true, + metadata: { channel: 'slack' }, + }), + }, +})); + +jest.mock('../src/services/blockchain-service', () => ({ + blockchainService: { + logReminderEvent: jest.fn().mockResolvedValue({ success: true }), + }, +})); + +jest.mock('../src/services/user-preference-service', () => ({ + userPreferenceService: { + getPreferences: jest.fn().mockResolvedValue({ + user_id: 'user-1', + notification_channels: ['slack'], + reminder_timing: [7, 3, 1], + email_opt_ins: { + marketing: false, + reminders: false, + updates: true, + }, + automation_flags: { + auto_renew: false, + auto_retry: true, + }, + risk_notification_threshold: 'HIGH', + quiet_hours_enabled: false, + quiet_hours_start: '22:00', + quiet_hours_end: '07:00', + quiet_hours_timezone: 'UTC', + critical_alerts_only: false, + currency: 'USD', + timezone: 'UTC', + locale: 'en', + calendar_sync_enabled: false, + calendar_export_reminders: false, + updated_at: new Date().toISOString(), + }), + }, +})); + +describe('ReminderEngine Slack delivery', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (userPreferenceService.getPreferences as jest.Mock).mockResolvedValue({ + user_id: 'user-1', + notification_channels: ['slack'], + reminder_timing: [7, 3, 1], + email_opt_ins: { + marketing: false, + reminders: false, + updates: true, + }, + automation_flags: { + auto_renew: false, + auto_retry: true, + }, + risk_notification_threshold: 'HIGH', + quiet_hours_enabled: false, + quiet_hours_start: '22:00', + quiet_hours_end: '07:00', + quiet_hours_timezone: 'UTC', + critical_alerts_only: false, + currency: 'USD', + timezone: 'UTC', + locale: 'en', + calendar_sync_enabled: false, + calendar_export_reminders: false, + updated_at: new Date().toISOString(), + }); + + (slackService.sendReminderNotification as jest.Mock).mockResolvedValue({ + success: true, + metadata: { channel: 'slack' }, + }); + + (blockchainService.logReminderEvent as jest.Mock).mockResolvedValue({ + success: true, + }); + + (supabase.from as jest.Mock).mockImplementation((table: string) => { + if (table === 'reminder_schedules') { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + upsert: jest.fn().mockResolvedValue({ error: null }), + single: jest.fn().mockResolvedValue({ data: null, error: { code: 'PGRST116' } }), + }; + } + + if (table === 'subscriptions') { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { + id: 'sub-1', + user_id: 'user-1', + name: 'Netflix', + provider: 'Netflix', + category: 'Streaming', + price: 15.99, + billing_cycle: 'monthly', + status: 'active', + next_billing_date: '2026-06-01T00:00:00Z', + logo_url: null, + website_url: null, + renewal_url: 'https://netflix.com/account', + notes: null, + tags: [], + expired_at: null, + active_until: '2026-06-01T00:00:00Z', + is_trial: false, + trial_ends_at: null, + trial_converts_to_price: null, + credit_card_required: false, + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + }, + error: null, + }), + }; + } + + if (table === 'user_preferences') { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { + user_id: 'user-1', + notification_channels: ['slack'], + reminder_timing: [7, 3, 1], + email_opt_ins: { + marketing: false, + reminders: false, + updates: true, + }, + automation_flags: { + auto_renew: false, + auto_retry: true, + }, + risk_notification_threshold: 'HIGH', + quiet_hours_enabled: false, + quiet_hours_start: '22:00', + quiet_hours_end: '07:00', + quiet_hours_timezone: 'UTC', + critical_alerts_only: false, + currency: 'USD', + timezone: 'UTC', + locale: 'en', + calendar_sync_enabled: false, + calendar_export_reminders: false, + updated_at: new Date().toISOString(), + }, + error: null, + }), + }; + } + + if (table === 'notification_deliveries') { + return { + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { + id: 'delivery-1', + reminder_schedule_id: 'reminder-1', + user_id: 'user-1', + channel: 'slack', + status: 'pending', + attempt_count: 0, + max_attempts: 3, + last_attempt_at: null, + next_retry_at: null, + error_message: null, + metadata: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + error: null, + }), + }), + }), + update: jest.fn().mockReturnThis(), + eq: jest.fn().mockResolvedValue({ error: null }), + }; + } + + if (table === 'blockchain_logs') { + return { + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'log-1' }, + error: null, + }), + }), + }), + update: jest.fn().mockReturnThis(), + eq: jest.fn().mockResolvedValue({ error: null }), + }; + } + + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + not: jest.fn().mockReturnThis(), + gt: jest.fn().mockReturnThis(), + lte: jest.fn().mockReturnThis(), + lt: jest.fn().mockReturnThis(), + in: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + upsert: jest.fn().mockResolvedValue({ error: null }), + single: jest.fn().mockResolvedValue({ data: null, error: { code: 'PGRST116' } }), + }; + }); + }); + + it('sends Slack notifications and records delivery status', async () => { + (supabase.from as jest.Mock).mockImplementation((table: string) => { + if (table === 'reminder_schedules') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ + data: [ + { + id: 'reminder-1', + subscription_id: 'sub-1', + user_id: 'user-1', + reminder_date: '2026-05-25', + reminder_type: 'renewal', + days_before: 7, + status: 'pending', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + error: null, + }), + }), + }), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: null }), + }), + }; + } + + if (table === 'subscriptions') { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { + id: 'sub-1', + user_id: 'user-1', + name: 'Netflix', + provider: 'Netflix', + category: 'Streaming', + price: 15.99, + billing_cycle: 'monthly', + status: 'active', + next_billing_date: '2026-06-01T00:00:00Z', + logo_url: null, + website_url: null, + renewal_url: 'https://netflix.com/account', + notes: null, + tags: [], + expired_at: null, + active_until: '2026-06-01T00:00:00Z', + is_trial: false, + trial_ends_at: null, + trial_converts_to_price: null, + credit_card_required: false, + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + }, + error: null, + }), + }; + } + + if (table === 'notification_deliveries') { + return { + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { + id: 'delivery-1', + reminder_schedule_id: 'reminder-1', + user_id: 'user-1', + channel: 'slack', + status: 'pending', + attempt_count: 0, + max_attempts: 3, + last_attempt_at: null, + next_retry_at: null, + error_message: null, + metadata: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + error: null, + }), + }), + }), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: null }), + }), + }; + } + + if (table === 'user_preferences') { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { + user_id: 'user-1', + notification_channels: ['slack'], + reminder_timing: [7, 3, 1], + email_opt_ins: { + marketing: false, + reminders: false, + updates: true, + }, + automation_flags: { + auto_renew: false, + auto_retry: true, + }, + risk_notification_threshold: 'HIGH', + quiet_hours_enabled: false, + quiet_hours_start: '22:00', + quiet_hours_end: '07:00', + quiet_hours_timezone: 'UTC', + critical_alerts_only: false, + currency: 'USD', + timezone: 'UTC', + locale: 'en', + calendar_sync_enabled: false, + calendar_export_reminders: false, + updated_at: new Date().toISOString(), + }, + error: null, + }), + }; + } + + if (table === 'blockchain_logs') { + return { + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'log-1' }, + error: null, + }), + }), + }), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: null }), + }), + }; + } + + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + not: jest.fn().mockReturnThis(), + gt: jest.fn().mockReturnThis(), + lte: jest.fn().mockReturnThis(), + lt: jest.fn().mockReturnThis(), + in: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + upsert: jest.fn().mockResolvedValue({ error: null }), + single: jest.fn().mockResolvedValue({ data: null, error: { code: 'PGRST116' } }), + }; + }); + + const engine = new ReminderEngine(); + await (engine as any).processReminder({ + id: 'reminder-1', + subscription_id: 'sub-1', + user_id: 'user-1', + reminder_date: '2026-05-25', + reminder_type: 'renewal', + days_before: 7, + status: 'pending', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + expect(slackService.sendReminderNotification).toHaveBeenCalledTimes(1); + expect(blockchainService.logReminderEvent).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + subscription: expect.objectContaining({ id: 'sub-1' }), + }), + ['slack'], + ); + }); +}); diff --git a/backend/tests/slack-service.test.ts b/backend/tests/slack-service.test.ts new file mode 100644 index 00000000..af55706f --- /dev/null +++ b/backend/tests/slack-service.test.ts @@ -0,0 +1,81 @@ +import { SlackService } from '../src/services/slack-service'; +import { NotificationPayload } from '../src/types/reminder'; + +jest.mock('../src/config/logger', () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + __esModule: true, +})); + +describe('SlackService', () => { + const payload: NotificationPayload = { + title: 'Renewal reminder', + body: 'Netflix renews soon', + subscription: { + id: 'sub-1', + user_id: 'user-1', + email_account_id: null, + merchant_id: null, + name: 'Netflix', + provider: 'Netflix', + category: 'Streaming', + price: 15.99, + billing_cycle: 'monthly', + status: 'active', + next_billing_date: '2026-06-01T00:00:00Z', + logo_url: null, + website_url: null, + renewal_url: 'https://netflix.com/account', + notes: null, + tags: [], + expired_at: null, + active_until: '2026-06-01T00:00:00Z', + is_trial: false, + trial_ends_at: null, + trial_converts_to_price: null, + credit_card_required: false, + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + } as any, + reminderType: 'renewal', + daysBefore: 7, + renewalDate: '2026-06-01T00:00:00Z', + }; + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('posts a Slack message when configured', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch' as any).mockResolvedValue({ + ok: true, + status: 200, + text: async () => '', + } as Response); + + const service = new SlackService('https://hooks.slack.com/services/T000/B000/TEST'); + const result = await service.sendReminderNotification(payload); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + expect(result.metadata?.channel).toBe('slack'); + }); + + it('marks non-2xx Slack responses as retryable when appropriate', async () => { + jest.spyOn(globalThis, 'fetch' as any).mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'upstream error', + } as Response); + + const service = new SlackService('https://hooks.slack.com/services/T000/B000/TEST'); + const result = await service.sendReminderNotification(payload); + + expect(result.success).toBe(false); + expect(result.metadata?.retryable).toBe(true); + }); +}); diff --git a/client/lib/api/user-preferences.ts b/client/lib/api/user-preferences.ts index 8bf8b4a9..eba07f91 100644 --- a/client/lib/api/user-preferences.ts +++ b/client/lib/api/user-preferences.ts @@ -2,7 +2,7 @@ import { apiGet, apiPatch } from "@/lib/api" export interface UserPreferences { user_id: string - notification_channels: ('email' | 'push')[] + notification_channels: ('email' | 'push' | 'telegram' | 'slack')[] reminder_timing: number[] email_opt_ins: { marketing: boolean @@ -26,7 +26,7 @@ export interface UserPreferences { } export interface UserPreferencesUpdateInput { - notification_channels?: ('email' | 'push')[] + notification_channels?: ('email' | 'push' | 'telegram' | 'slack')[] reminder_timing?: number[] email_opt_ins?: { marketing?: boolean @@ -129,4 +129,4 @@ export async function testQuietHours(testTime?: string): Promise