diff --git a/backend/.env.example b/backend/.env.example index 095997c..3e20c96 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -60,4 +60,9 @@ RATE_LIMIT_ADMIN_MAX=100 RATE_LIMIT_ADMIN_WINDOW_HOURS=1 # External API Keys (optional) -ANTHROPIC_API_KEY=your_anthropic_api_key_for_classification \ No newline at end of file +ANTHROPIC_API_KEY=your_anthropic_api_key_for_classification +# Sentry +SENTRY_DSN=your_sentry_dsn_here +SENTRY_AUTH_TOKEN=your_sentry_auth_token_here +SENTRY_ORG=your_sentry_org_here +SENTRY_PROJECT=your_sentry_project_here diff --git a/backend/package.json b/backend/package.json index d12b12b..357d3e5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,8 @@ "test": "jest" }, "dependencies": { + "@sentry/node": "^10.46.0", + "@sentry/profiling-node": "^10.46.0", "@supabase/supabase-js": "^2.47.10", "@types/cookie-parser": "^1.4.10", "@types/uuid": "^10.0.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index f4bec2c..884113f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,9 +1,21 @@ import express from 'express'; import cookieParser from 'cookie-parser'; import dotenv from 'dotenv'; +import * as Sentry from '@sentry/node'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; + // Load environment variables before importing other modules dotenv.config(); +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV, + integrations: [nodeProfilingIntegration()], + tracesSampleRate: 0.1, + profilesSampleRate: 0.1, +}); + + import logger from './config/logger'; import { requestIdMiddleware } from './middleware/requestContext'; import { requestLoggerMiddleware } from './middleware/requestLogger'; @@ -23,6 +35,10 @@ import { expiryService } from './services/expiry-service'; import { scheduleAutoResume } from './jobs/auto-resume'; const app = express(); + +// Add Sentry request handler before routes +app.use(Sentry.Handlers.requestHandler()); + const PORT = process.env.PORT || 3001; const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'development-admin-key'; @@ -178,6 +194,8 @@ app.post('/api/admin/expiry/process', createAdminLimiter(), adminAuth, async (re } }); +// Add Sentry error handler after all routes +app.use(Sentry.Handlers.errorHandler()); // Start server const server = app.listen(PORT, async () => { diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 2db3eae..a7c4cf3 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'; import { supabase } from '../config/database'; import logger from '../config/logger'; import { setRequestUserId } from './requestContext'; +import * as Sentry from '@sentry/node'; export interface AuthenticatedRequest extends Request { user?: { @@ -57,6 +58,8 @@ export async function authenticate( email: user.email || '', }; setRequestUserId(user.id); + Sentry.setUser({ id: user.id, email: user.email }); + next(); } catch (error) { @@ -95,7 +98,9 @@ export async function optionalAuthenticate( email: user.email || '', }; setRequestUserId(user.id); + Sentry.setUser({ id: user.id, email: user.email }); } + } next(); diff --git a/backend/src/services/risk-detection/risk-detection-service.ts b/backend/src/services/risk-detection/risk-detection-service.ts index 6a826bb..5e2b2e2 100644 --- a/backend/src/services/risk-detection/risk-detection-service.ts +++ b/backend/src/services/risk-detection/risk-detection-service.ts @@ -1,348 +1,344 @@ -The following is the integrated code for the `RiskDetectionService`, resolving the merge conflicts by including the `webhookService` and ensuring consistent string quoting: - -```typescript -/** - * Risk Detection Service - * Core service for computing and managing subscription risk scores - */ - -import { supabase } from "../../config/database"; -import logger from "../../config/logger"; -import { Subscription } from "../../types/subscription"; -import { webhookService } from "../webhook-service"; -import { - RiskAssessment, - RiskScore, - RiskContext, - RiskWeightConfig, - DEFAULT_RISK_WEIGHTS, - RiskRecalculationResult, - RenewalAttempt, - RiskFactor, - RiskLevel, -} from "../../types/risk-detection"; -import { ConsecutiveFailuresEvaluator } from "./evaluators/consecutive-failures-evaluator"; -import { BalanceProjectionEvaluator } from "./evaluators/balance-projection-evaluator"; -import { ApprovalExpirationEvaluator } from "./evaluators/approval-expiration-evaluator"; -import { RiskAggregator } from "./risk-aggregator"; - -export class RiskDetectionService { - private consecutiveFailuresEvaluator: ConsecutiveFailuresEvaluator; - private balanceProjectionEvaluator: BalanceProjectionEvaluator; - private approvalExpirationEvaluator: ApprovalExpirationEvaluator; - private aggregator: RiskAggregator; - private config: RiskWeightConfig; - - constructor(config: RiskWeightConfig = DEFAULT_RISK_WEIGHTS) { - this.config = config; - this.consecutiveFailuresEvaluator = new ConsecutiveFailuresEvaluator( - config, - ); - this.balanceProjectionEvaluator = new BalanceProjectionEvaluator(config); - this.approvalExpirationEvaluator = new ApprovalExpirationEvaluator(config); - this.aggregator = new RiskAggregator(); - } - - /** - * Compute risk level for a single subscription - */ - async computeRiskLevel(subscriptionId: string): Promise { - const startTime = Date.now(); - - try { - // Fetch subscription - const { data: subscription, error } = await supabase - .from("subscriptions") - .select("*") - .eq("id", subscriptionId) - .single(); - - if (error || !subscription) { - throw new Error(`Subscription not found: ${subscriptionId}`); - } - - if (subscription.status === "paused") { - logger.info( - `Skipping risk calculation — subscription ${subscriptionId} is paused`, - ); - return { - subscription_id: subscriptionId, - risk_level: "none" as RiskLevel, - risk_factors: [], - computed_at: new Date().toISOString(), - skipped: true, - }; - } - - // Build risk context - const context: RiskContext = { - currentTimestamp: new Date(), - // Note: projectedBalance would be calculated by a separate service - // For now, we'll skip balance projection if not provided - }; - - // Run all evaluators - const riskWeights = await Promise.all([ - this.consecutiveFailuresEvaluator.evaluate(subscription, context), - this.balanceProjectionEvaluator.evaluate(subscription, context), - this.approvalExpirationEvaluator.evaluate(subscription, context), - ]); - - // Aggregate risk level - const riskLevel = this.aggregator.aggregate(riskWeights); - - // Convert risk weights to risk factors for storage - const riskFactors: RiskFactor[] = riskWeights.map((w) => ({ - factor_type: w.type, - weight: w.weight, - details: w.details, - })); - - const assessment: RiskAssessment = { - subscription_id: subscriptionId, - risk_level: riskLevel, - risk_factors: riskFactors, - computed_at: new Date().toISOString(), - }; - - const duration = Date.now() - startTime; - logger.info("Risk computed for subscription", { - subscription_id: subscriptionId, - risk_level: riskLevel, - duration_ms: duration, - }); - - // Log calculation details - logger.debug("Risk calculation details", { - subscription_id: subscriptionId, - risk_factors: riskFactors, - risk_level: riskLevel, - }); - - return assessment; - } catch (error) { - logger.error("Error computing risk level:", error); - throw error; - } - } - - /** - * Save risk score to database - */ - async saveRiskScore( - assessment: RiskAssessment, - userId: string, - ): Promise { - try { - // Get old score to check for change - const { data: oldScore } = await supabase - .from("subscription_risk_scores") - .select("risk_level") - .eq("subscription_id", assessment.subscription_id) - .single(); - - const { data, error } = await supabase - .from("subscription_risk_scores") - .upsert( - { - subscription_id: assessment.subscription_id, - user_id: userId, - risk_level: assessment.risk_level, - risk_factors: assessment.risk_factors, - last_calculated_at: assessment.computed_at, - updated_at: new Date().toISOString(), - }, - { - onConflict: "subscription_id", - }, - ) - .select() - .single(); - - if (error) { - throw new Error(`Failed to save risk score: ${error.message}`); - } - - if (data && oldScore && oldScore.risk_level !== assessment.risk_level) { - webhookService.dispatchEvent(userId, "subscription.risk_score_changed", { - subscription_id: assessment.subscription_id, - old_risk_level: oldScore.risk_level, - new_risk_level: assessment.risk_level, - risk_factors: assessment.risk_factors - }).catch(err => { - logger.error("Failed to dispatch subscription.risk_score_changed webhook:", err); - }); - } - - return data as RiskScore; - } catch (error) { - logger.error("Error saving risk score:", error); - throw error; - } - } - - /** - * Get risk score for a subscription - */ - async getRiskScore( - subscriptionId: string, - userId: string, - ): Promise { - try { - const { data, error } = await supabase - .from("subscription_risk_scores") - .select("*") - .eq("subscription_id", subscriptionId) - .eq("user_id", userId) - .single(); - - if (error || !data) { - throw new Error( - `Risk score not found for subscription: ${subscriptionId}`, - ); - } - - return data as RiskScore; - } catch (error) { - logger.error("Error fetching risk score:", error); - throw error; - } - } - - /** - * Get all risk scores for a user - */ - async getUserRiskScores(userId: string): Promise { - try { - const { data, error } = await supabase - .from("subscription_risk_scores") - .select("*") - .eq("user_id", userId) - .order("last_calculated_at", { ascending: false }); - - if (error) { - throw new Error(`Failed to fetch user risk scores: ${error.message}`); - } - - return (data || []) as RiskScore[]; - } catch (error) { - logger.error("Error fetching user risk scores:", error); - throw error; - } - } - - /** - * Recalculate risk for all active subscriptions - */ - async recalculateAllRisks(): Promise { - const startTime = Date.now(); - const result: RiskRecalculationResult = { - total: 0, - successful: 0, - failed: 0, - errors: [], - duration_ms: 0, - }; - - try { - logger.info("Starting risk recalculation for all active subscriptions"); - - // Fetch all active subscriptions in batches - const batchSize = 100; - let offset = 0; - let hasMore = true; - - while (hasMore) { - const { data: subscriptions, error } = await supabase - .from("subscriptions") - .select("*") - .eq("status", "active") - .range(offset, offset + batchSize - 1); - - if (error) { - logger.error("Error fetching subscriptions:", error); - throw error; - } - - if (!subscriptions || subscriptions.length === 0) { - hasMore = false; - break; - } - - result.total += subscriptions.length; - - // Process each subscription - for (const subscription of subscriptions) { - try { - const assessment = await this.computeRiskLevel(subscription.id); - await this.saveRiskScore(assessment, subscription.user_id); - result.successful++; - } catch (error) { - result.failed++; - result.errors.push({ - subscription_id: subscription.id, - error: error instanceof Error ? error.message : String(error), - }); - logger.error( - `Failed to recalculate risk for subscription ${subscription.id}:`, - error, - ); - } - } - - offset += batchSize; - hasMore = subscriptions.length === batchSize; - } - - result.duration_ms = Date.now() - startTime; - - logger.info("Risk recalculation completed", { - total: result.total, - successful: result.successful, - failed: result.failed, - duration_ms: result.duration_ms, - }); - - return result; - } catch (error) { - result.duration_ms = Date.now() - startTime; - logger.error("Error in risk recalculation:", error); - throw error; - } - } - - /** - * Record a renewal attempt - */ - async recordRenewalAttempt( - subscriptionId: string, - success: boolean, - errorMessage?: string, - ): Promise { - try { - const { error } = await supabase - .from("subscription_renewal_attempts") - .insert({ - subscription_id: subscriptionId, - success, - error_message: errorMessage || null, - attempt_date: new Date().toISOString(), - }); - - if (error) { - throw new Error(`Failed to record renewal attempt: ${error.message}`); - } - - logger.info("Renewal attempt recorded", { - subscription_id: subscriptionId, - success, - }); - } catch (error) { - logger.error("Error recording renewal attempt:", error); - throw error; - } - } -} - -export const riskDetectionService = new RiskDetectionService(); -``` \ No newline at end of file +/** + * Risk Detection Service + * Core service for computing and managing subscription risk scores + */ + +import { supabase } from "../../config/database"; +import logger from "../../config/logger"; +import { Subscription } from "../../types/subscription"; +import { webhookService } from "../webhook-service"; +import { + RiskAssessment, + RiskScore, + RiskContext, + RiskWeightConfig, + DEFAULT_RISK_WEIGHTS, + RiskRecalculationResult, + RenewalAttempt, + RiskFactor, + RiskLevel, +} from "../../types/risk-detection"; +import { ConsecutiveFailuresEvaluator } from "./evaluators/consecutive-failures-evaluator"; +import { BalanceProjectionEvaluator } from "./evaluators/balance-projection-evaluator"; +import { ApprovalExpirationEvaluator } from "./evaluators/approval-expiration-evaluator"; +import { RiskAggregator } from "./risk-aggregator"; + +export class RiskDetectionService { + private consecutiveFailuresEvaluator: ConsecutiveFailuresEvaluator; + private balanceProjectionEvaluator: BalanceProjectionEvaluator; + private approvalExpirationEvaluator: ApprovalExpirationEvaluator; + private aggregator: RiskAggregator; + private config: RiskWeightConfig; + + constructor(config: RiskWeightConfig = DEFAULT_RISK_WEIGHTS) { + this.config = config; + this.consecutiveFailuresEvaluator = new ConsecutiveFailuresEvaluator( + config, + ); + this.balanceProjectionEvaluator = new BalanceProjectionEvaluator(config); + this.approvalExpirationEvaluator = new ApprovalExpirationEvaluator(config); + this.aggregator = new RiskAggregator(); + } + + /** + * Compute risk level for a single subscription + */ + async computeRiskLevel(subscriptionId: string): Promise { + const startTime = Date.now(); + + try { + // Fetch subscription + const { data: subscription, error } = await supabase + .from("subscriptions") + .select("*") + .eq("id", subscriptionId) + .single(); + + if (error || !subscription) { + throw new Error(`Subscription not found: ${subscriptionId}`); + } + + if (subscription.status === "paused") { + logger.info( + `Skipping risk calculation — subscription ${subscriptionId} is paused`, + ); + return { + subscription_id: subscriptionId, + risk_level: "none" as RiskLevel, + risk_factors: [], + computed_at: new Date().toISOString(), + skipped: true, + }; + } + + // Build risk context + const context: RiskContext = { + currentTimestamp: new Date(), + // Note: projectedBalance would be calculated by a separate service + // For now, we'll skip balance projection if not provided + }; + + // Run all evaluators + const riskWeights = await Promise.all([ + this.consecutiveFailuresEvaluator.evaluate(subscription, context), + this.balanceProjectionEvaluator.evaluate(subscription, context), + this.approvalExpirationEvaluator.evaluate(subscription, context), + ]); + + // Aggregate risk level + const riskLevel = this.aggregator.aggregate(riskWeights); + + // Convert risk weights to risk factors for storage + const riskFactors: RiskFactor[] = riskWeights.map((w) => ({ + factor_type: w.type, + weight: w.weight, + details: w.details, + })); + + const assessment: RiskAssessment = { + subscription_id: subscriptionId, + risk_level: riskLevel, + risk_factors: riskFactors, + computed_at: new Date().toISOString(), + }; + + const duration = Date.now() - startTime; + logger.info("Risk computed for subscription", { + subscription_id: subscriptionId, + risk_level: riskLevel, + duration_ms: duration, + }); + + // Log calculation details + logger.debug("Risk calculation details", { + subscription_id: subscriptionId, + risk_factors: riskFactors, + risk_level: riskLevel, + }); + + return assessment; + } catch (error) { + logger.error("Error computing risk level:", error); + throw error; + } + } + + /** + * Save risk score to database + */ + async saveRiskScore( + assessment: RiskAssessment, + userId: string, + ): Promise { + try { + // Get old score to check for change + const { data: oldScore } = await supabase + .from("subscription_risk_scores") + .select("risk_level") + .eq("subscription_id", assessment.subscription_id) + .single(); + + const { data, error } = await supabase + .from("subscription_risk_scores") + .upsert( + { + subscription_id: assessment.subscription_id, + user_id: userId, + risk_level: assessment.risk_level, + risk_factors: assessment.risk_factors, + last_calculated_at: assessment.computed_at, + updated_at: new Date().toISOString(), + }, + { + onConflict: "subscription_id", + }, + ) + .select() + .single(); + + if (error) { + throw new Error(`Failed to save risk score: ${error.message}`); + } + + if (data && oldScore && oldScore.risk_level !== assessment.risk_level) { + webhookService.dispatchEvent(userId, "subscription.risk_score_changed", { + subscription_id: assessment.subscription_id, + old_risk_level: oldScore.risk_level, + new_risk_level: assessment.risk_level, + risk_factors: assessment.risk_factors + }).catch(err => { + logger.error("Failed to dispatch subscription.risk_score_changed webhook:", err); + }); + } + + return data as RiskScore; + } catch (error) { + logger.error("Error saving risk score:", error); + throw error; + } + } + + /** + * Get risk score for a subscription + */ + async getRiskScore( + subscriptionId: string, + userId: string, + ): Promise { + try { + const { data, error } = await supabase + .from("subscription_risk_scores") + .select("*") + .eq("subscription_id", subscriptionId) + .eq("user_id", userId) + .single(); + + if (error || !data) { + throw new Error( + `Risk score not found for subscription: ${subscriptionId}`, + ); + } + + return data as RiskScore; + } catch (error) { + logger.error("Error fetching risk score:", error); + throw error; + } + } + + /** + * Get all risk scores for a user + */ + async getUserRiskScores(userId: string): Promise { + try { + const { data, error } = await supabase + .from("subscription_risk_scores") + .select("*") + .eq("user_id", userId) + .order("last_calculated_at", { ascending: false }); + + if (error) { + throw new Error(`Failed to fetch user risk scores: ${error.message}`); + } + + return (data || []) as RiskScore[]; + } catch (error) { + logger.error("Error fetching user risk scores:", error); + throw error; + } + } + + /** + * Recalculate risk for all active subscriptions + */ + async recalculateAllRisks(): Promise { + const startTime = Date.now(); + const result: RiskRecalculationResult = { + total: 0, + successful: 0, + failed: 0, + errors: [], + duration_ms: 0, + }; + + try { + logger.info("Starting risk recalculation for all active subscriptions"); + + // Fetch all active subscriptions in batches + const batchSize = 100; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const { data: subscriptions, error } = await supabase + .from("subscriptions") + .select("*") + .eq("status", "active") + .range(offset, offset + batchSize - 1); + + if (error) { + logger.error("Error fetching subscriptions:", error); + throw error; + } + + if (!subscriptions || subscriptions.length === 0) { + hasMore = false; + break; + } + + result.total += subscriptions.length; + + // Process each subscription + for (const subscription of subscriptions) { + try { + const assessment = await this.computeRiskLevel(subscription.id); + await this.saveRiskScore(assessment, subscription.user_id); + result.successful++; + } catch (error) { + result.failed++; + result.errors.push({ + subscription_id: subscription.id, + error: error instanceof Error ? error.message : String(error), + }); + logger.error( + `Failed to recalculate risk for subscription ${subscription.id}:`, + error, + ); + } + } + + offset += batchSize; + hasMore = subscriptions.length === batchSize; + } + + result.duration_ms = Date.now() - startTime; + + logger.info("Risk recalculation completed", { + total: result.total, + successful: result.successful, + failed: result.failed, + duration_ms: result.duration_ms, + }); + + return result; + } catch (error) { + result.duration_ms = Date.now() - startTime; + logger.error("Error in risk recalculation:", error); + throw error; + } + } + + /** + * Record a renewal attempt + */ + async recordRenewalAttempt( + subscriptionId: string, + success: boolean, + errorMessage?: string, + ): Promise { + try { + const { error } = await supabase + .from("subscription_renewal_attempts") + .insert({ + subscription_id: subscriptionId, + success, + error_message: errorMessage || null, + attempt_date: new Date().toISOString(), + }); + + if (error) { + throw new Error(`Failed to record renewal attempt: ${error.message}`); + } + + logger.info("Renewal attempt recorded", { + subscription_id: subscriptionId, + success, + }); + } catch (error) { + logger.error("Error recording renewal attempt:", error); + throw error; + } + } +} + +export const riskDetectionService = new RiskDetectionService(); \ No newline at end of file diff --git a/client/components/modals/cancellation-guide-modal.tsx b/client/components/modals/cancellation-guide-modal.tsx index 491f415..e5fcd4e 100644 --- a/client/components/modals/cancellation-guide-modal.tsx +++ b/client/components/modals/cancellation-guide-modal.tsx @@ -275,7 +275,7 @@ export default function CancellationGuideModal({
-
-
- -
- -
-

- {sub.status === "expiring" ? `Expires in ${sub.renewsIn} days` : `Renewal in ${sub.renewsIn} days`} -

- - {sub.status === "expiring" ? "Expiring" : "Active"} - -
-