From 912081188044a3d1a527c70ebecec4459a7321ad Mon Sep 17 00:00:00 2001 From: Tambeej <49399681+Tambeej@users.noreply.github.com> Date: Thu, 7 May 2026 14:22:03 +0300 Subject: [PATCH 1/5] feat: Add POST /api/v1/analysis/:offerId/enhanced endpoint with paidAccess middleware - Implement paidAccess middleware checking user.paidAnalyses flag - Implement protect (auth) middleware with Firebase JWT verification - Implement rateLimit middleware (5 req/min for paid endpoint) - Implement analysisValidator with validateOfferId - Implement offerService.findByIdAndUserId for ownership validation - Implement portfolioService.getUserPortfolio for portfolio data - Implement reportService.generateEnhancedReport with AI + fallback - Implement analysisController.generateEnhancedReport controller - Wire up analysis routes with full middleware chain - Add comprehensive tests for the enhanced endpoint --- .env.example | 62 +- __tests__/analysisEnhanced.test.js | 619 ++++++++--- __tests__/analysisValidator.test.js | 150 +-- __tests__/paidAccess.test.js | 147 +-- __tests__/reportService.test.js | 806 +++++---------- docs/API.md | 1356 +++---------------------- package.json | 67 +- src/config/collections.js | 678 +------------ src/config/db.js | 28 +- src/config/firebase.js | 124 +-- src/controllers/analysisController.js | 166 +-- src/index.js | 168 +-- src/middleware/auth.js | 82 +- src/middleware/errorHandler.js | 233 +---- src/middleware/paidAccess.js | 82 +- src/middleware/rateLimit.js | 65 +- src/middleware/security.js | 496 +-------- src/middleware/validate.js | 54 +- src/routes/analysis.js | 52 +- src/services/aiService.js | 146 +-- src/services/offerService.js | 514 +--------- src/services/portfolioService.js | 52 + src/services/reportService.js | 1059 +++++++------------ src/utils/errors.js | 200 +--- src/utils/jwt.js | 119 +-- src/utils/logger.js | 72 +- src/utils/response.js | 116 +-- src/validators/analysisValidator.js | 137 +-- 28 files changed, 1990 insertions(+), 5860 deletions(-) create mode 100644 src/services/portfolioService.js diff --git a/.env.example b/.env.example index 43fe47a..5e0d561 100644 --- a/.env.example +++ b/.env.example @@ -2,53 +2,35 @@ NODE_ENV=development PORT=5000 -# JWT -JWT_SECRET=your_jwt_secret_here_min_32_chars -JWT_REFRESH_SECRET=your_refresh_secret_here_min_32_chars -# Token lifetimes (optional – defaults shown) -# Access token: short-lived to minimise exposure window (architecture: 15m) -JWT_EXPIRES_IN=15m -# Refresh token: long-lived; rotated on every use and stored in Firestore -JWT_REFRESH_EXPIRES_IN=7d - -# Google Cloud / Firebase Admin (Firestore) -# Option A – service account JSON file path (local dev) -GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json -# Option B – individual credential fields (CI / Render) -FIREBASE_PROJECT_ID=your-gcp-project-id -FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxx@your-project.iam.gserviceaccount.com +# Firebase Admin SDK +FIREBASE_PROJECT_ID=your-firebase-project-id +FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" -# Cloudinary -CLOUDINARY_CLOUD_NAME=your_cloud_name -CLOUDINARY_API_KEY=your_api_key -CLOUDINARY_API_SECRET=your_api_secret - # OpenAI OPENAI_API_KEY=sk-... -# Stripe (Payment Integration) -# Secret key from Stripe Dashboard → Developers → API keys -STRIPE_SECRET_KEY=sk_test_... -# Webhook signing secret from Stripe Dashboard → Developers → Webhooks + +# Stripe +STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... -# Price ID for the expert analysis product (from Stripe Dashboard → Products) -# If not set, an ad-hoc price of ₪149 is used STRIPE_PRICE_ID=price_... -# CORS – set to your frontend origin -CORS_ORIGIN=http://localhost:3000 +# Cloudinary +CLOUDINARY_CLOUD_NAME=your-cloud-name +CLOUDINARY_API_KEY=your-api-key +CLOUDINARY_API_SECRET=your-api-secret + +# JWT +JWT_SECRET=your-super-secret-jwt-key-min-32-chars +JWT_EXPIRES_IN=7d -# Bank of Israel API (optional – defaults to official BOI SDMX endpoint) -# Override only for testing or if the BOI URL changes -BOI_API_BASE_URL=https://edge.boi.gov.il/FusionEdgeServer/sdmx/v2/data/dataflow/BOI +# CORS +CORS_ORIGIN=http://localhost:3000 -# Email (optional) -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=user@example.com -SMTP_PASS=password -EMAIL_FROM=noreply@morty.app +# Rate Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=100 +PAID_RATE_LIMIT_MAX=5 -# Logging -LOG_LEVEL=info +# BOI Rates API +BOI_RATES_API_URL=https://edge.boi.org.il/FusionEdgeServer/sdmx/v2/data/dataflow/BOI.STATISTICS/BSS_D_INTEREST_RATES_MORTGAGES diff --git a/__tests__/analysisEnhanced.test.js b/__tests__/analysisEnhanced.test.js index 58bdc6c..5172171 100644 --- a/__tests__/analysisEnhanced.test.js +++ b/__tests__/analysisEnhanced.test.js @@ -1,167 +1,510 @@ +'use strict'; + /** - * Enhanced Analysis Endpoint Tests + * Integration tests for POST /api/v1/analysis/:offerId/enhanced * - * Integration tests for POST /api/v1/analysis/enhanced/:offerId - * Tests authentication, paid access, validation, and report generation. + * Mocks: + * - Firebase Admin SDK (auth + firestore) + * - offerService + * - portfolioService + * - reportService */ -'use strict'; - -// Mock dependencies -jest.mock('../src/config/firestore', () => { - const mockDoc = { - get: jest.fn(), - set: jest.fn().mockResolvedValue(undefined), - update: jest.fn().mockResolvedValue(undefined), - }; - const mockCollection = jest.fn(() => ({ - doc: jest.fn(() => mockDoc), - add: jest.fn().mockResolvedValue({ id: 'mock-id' }), - where: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - get: jest.fn().mockResolvedValue({ empty: true, docs: [], size: 0 }), - })); - const mock = { - collection: mockCollection, - batch: jest.fn(() => ({ - set: jest.fn(), - commit: jest.fn().mockResolvedValue(undefined), - })), - _mockDoc: mockDoc, - }; - return mock; -}); +const request = require('supertest'); -jest.mock('../src/config/cloudinary', () => ({ - uploader: { - upload_stream: jest.fn(), - destroy: jest.fn(), - }, +// ─── Mock Firebase Admin ────────────────────────────────────────────────────── +jest.mock('../src/config/firebase', () => ({ + initializeFirebase: jest.fn(), + getAuth: jest.fn(() => ({ + verifyIdToken: jest.fn(), + })), + getFirestore: jest.fn(() => ({ + collection: jest.fn(), + })), + admin: {}, })); -jest.mock('../src/utils/jwt', () => ({ - verifyAccessToken: jest.fn(), - generateAccessToken: jest.fn(), - generateRefreshToken: jest.fn(), +// ─── Mock DB ────────────────────────────────────────────────────────────────── +jest.mock('../src/config/db', () => ({ + getDb: jest.fn(), })); -jest.mock('../src/services/ratesService', () => ({ - getCurrentAverages: jest.fn().mockResolvedValue({ - fixed: 4.65, - cpi: 3.15, - prime: 6.05, - variable: 4.95, - }), - getLatestRates: jest.fn().mockResolvedValue(null), - fetchAndStoreLatestRates: jest.fn().mockResolvedValue(null), - clearCache: jest.fn(), -})); +// ─── Mock Services ──────────────────────────────────────────────────────────── +jest.mock('../src/services/offerService'); +jest.mock('../src/services/portfolioService'); +jest.mock('../src/services/reportService'); -jest.mock('../src/cron/ratesCron', () => ({ - startRatesCron: jest.fn(), -})); +const { getAuth } = require('../src/config/firebase'); +const { getDb } = require('../src/config/db'); +const offerService = require('../src/services/offerService'); +const portfolioService = require('../src/services/portfolioService'); +const reportService = require('../src/services/reportService'); -jest.mock('../src/utils/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); +// ─── Test fixtures ──────────────────────────────────────────────────────────── +const VALID_OFFER_ID = 'offer123abc'; +const VALID_USER_ID = 'user456def'; +const VALID_TOKEN = 'valid-firebase-token'; -const request = require('supertest'); -const { verifyAccessToken } = require('../src/utils/jwt'); -const db = require('../src/config/firestore'); +const mockUser = { + uid: VALID_USER_ID, + email: 'test@example.com', + paidAnalyses: true, +}; + +const mockOffer = { + id: VALID_OFFER_ID, + userId: VALID_USER_ID, + bankName: 'Bank Hapoalim', + status: 'analyzed', + analysis: { + terms: { + loanAmount: 1500000, + termYears: 25, + interestRate: 5.2, + }, + }, +}; + +const mockPortfolio = { + id: 'portfolio789', + userId: VALID_USER_ID, + averageRate: 4.75, + tracks: [ + { type: 'fixed', rate: 4.75, amount: 750000 }, + { type: 'prime', rate: 4.5, amount: 750000 }, + ], +}; + +const mockEnhancedReport = { + tricks: [ + { + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + descriptionHe: 'תיאור בעברית', + descriptionEn: 'Description in English', + applicability: 'high', + riskLevel: 'medium', + potentialSavings: 22000, + }, + ], + negotiationScript: 'שלום, שמי [שם]...', + insights: [ + { + titleHe: 'ניתוח ריבית', + titleEn: 'Rate Analysis', + bodyHe: 'גוף בעברית', + bodyEn: 'Body in English', + icon: 'trending-down', + }, + ], + comparison: { + rateDelta: 0.45, + monthlySaving: 412, + totalSaving: 123600, + loanAmount: 1500000, + termYears: 25, + bankRate: 5.2, + portfolioRate: 4.75, + trackComparison: [], + }, + generatedAt: '2026-05-07T12:00:00.000Z', + generatedBy: 'ai', + processingTimeMs: 1500, +}; + +// ─── Setup ──────────────────────────────────────────────────────────────────── -// We need to require the app after mocks are set up let app; beforeAll(() => { - // Suppress startup logs + // Setup Firebase auth mock + getAuth.mockReturnValue({ + verifyIdToken: jest.fn().mockResolvedValue({ uid: VALID_USER_ID, email: mockUser.email }), + }); + + // Setup Firestore mock for user lookup in protect middleware + const mockUserDoc = { + exists: true, + data: () => mockUser, + }; + const mockCollection = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockUserDoc), + }), + }); + getDb.mockReturnValue({ collection: mockCollection }); + + // Load app after mocks are set up app = require('../src/index'); }); -const mockPortfolio = { - id: 'market_standard', - name: 'Market Standard', - nameHe: 'תיק שוק סטנדרטי', - termYears: 30, - tracks: [ - { type: 'fixed', percentage: 34, rate: 4.75, rateDisplay: '4.75%' }, - { type: 'prime', percentage: 33, rate: 5.9, rateDisplay: 'P-0.15%' }, - { type: 'cpi', percentage: 33, rate: 3.2, rateDisplay: '3.20% + מדד' }, - ], - monthlyRepayment: 5200, - totalCost: 1872000, - totalInterest: 672000, -}; +afterEach(() => { + jest.clearAllMocks(); -describe('POST /api/v1/analysis/enhanced/:offerId', () => { - beforeEach(() => { - jest.clearAllMocks(); + // Re-apply persistent mocks after clearAllMocks + getAuth.mockReturnValue({ + verifyIdToken: jest.fn().mockResolvedValue({ uid: VALID_USER_ID, email: mockUser.email }), + }); + + const mockUserDoc = { + exists: true, + data: () => mockUser, + }; + const mockCollection = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockUserDoc), + }), }); + getDb.mockReturnValue({ collection: mockCollection }); +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('POST /api/v1/analysis/:offerId/enhanced', () => { + // ── Authentication ────────────────────────────────────────────────────────── + + describe('Authentication', () => { + it('should return 401 when no Authorization header is provided', async () => { + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .expect(401); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('UNAUTHORIZED'); + }); + + it('should return 401 when Authorization header is malformed', async () => { + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', 'InvalidFormat token123') + .expect(401); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('UNAUTHORIZED'); + }); - it('should return 401 without authentication', async () => { - const res = await request(app) - .post('/api/v1/analysis/enhanced/offer-123') - .send({ portfolio: mockPortfolio }); + it('should return 401 when Firebase token is invalid', async () => { + getAuth.mockReturnValue({ + verifyIdToken: jest.fn().mockRejectedValue(new Error('Invalid token')), + }); - expect(res.status).toBe(401); - expect(res.body.success).toBe(false); + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', 'Bearer invalid-token') + .expect(401); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('UNAUTHORIZED'); + }); + + it('should return 401 when user document does not exist in Firestore', async () => { + getAuth.mockReturnValue({ + verifyIdToken: jest.fn().mockResolvedValue({ uid: 'ghost-user', email: 'ghost@test.com' }), + }); + + const mockCollection = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false }), + }), + }); + getDb.mockReturnValue({ collection: mockCollection }); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(401); + + expect(res.body.success).toBe(false); + }); }); - it('should return 403 when user has not paid', async () => { - // Mock auth - verifyAccessToken.mockReturnValue({ id: 'user-456' }); - - // Mock user lookup (auth middleware) - const mockUserDoc = { - exists: true, - id: 'user-456', - data: () => ({ - id: 'user-456', - email: 'test@example.com', - verified: true, - paidAnalyses: false, - }), - }; - - // The auth middleware and paidAccess middleware both call db.collection('users').doc(id).get() - db._mockDoc.get.mockResolvedValue(mockUserDoc); - - const res = await request(app) - .post('/api/v1/analysis/enhanced/offer-123') - .set('Authorization', 'Bearer valid-token') - .send({ portfolio: mockPortfolio }); - - expect(res.status).toBe(403); - expect(res.body.success).toBe(false); - expect(res.body.errorCode).toBe('PAYMENT_REQUIRED'); + // ── Paid Access ───────────────────────────────────────────────────────────── + + describe('Paid Access', () => { + it('should return 403 when user has not paid (paidAnalyses = false)', async () => { + const unpaidUser = { ...mockUser, paidAnalyses: false }; + const mockUserDoc = { exists: true, data: () => unpaidUser }; + const mockCollection = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockUserDoc), + }), + }); + getDb.mockReturnValue({ collection: mockCollection }); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(403); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('FORBIDDEN'); + }); + + it('should return 403 when user has no paidAnalyses field', async () => { + const unpaidUser = { uid: VALID_USER_ID, email: 'test@example.com' }; + const mockUserDoc = { exists: true, data: () => unpaidUser }; + const mockCollection = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockUserDoc), + }), + }); + getDb.mockReturnValue({ collection: mockCollection }); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(403); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('FORBIDDEN'); + }); + }); + + // ── Input Validation ──────────────────────────────────────────────────────── + + describe('Input Validation', () => { + it('should return 400 when offerId contains invalid characters', async () => { + const res = await request(app) + .post('/api/v1/analysis/invalid!@#offer/enhanced') + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(400); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should return 400 when offerId is too long', async () => { + const longId = 'a'.repeat(129); + const res = await request(app) + .post(`/api/v1/analysis/${longId}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(400); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + }); + + // ── Successful Generation ─────────────────────────────────────────────────── + + describe('Successful Report Generation', () => { + beforeEach(() => { + offerService.findByIdAndUserId.mockResolvedValue(mockOffer); + portfolioService.getUserPortfolio.mockResolvedValue(mockPortfolio); + reportService.generateEnhancedReport.mockResolvedValue(mockEnhancedReport); + }); + + it('should return 201 with enhanced report on first generation', async () => { + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(201); + + expect(res.body.success).toBe(true); + expect(res.body.data).toBeDefined(); + expect(res.body.data.tricks).toHaveLength(1); + expect(res.body.data.negotiationScript).toBe('שלום, שמי [שם]...'); + expect(res.body.data.insights).toHaveLength(1); + expect(res.body.data.comparison).toBeDefined(); + }); + + it('should call offerService.findByIdAndUserId with correct params', async () => { + await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`); + + expect(offerService.findByIdAndUserId).toHaveBeenCalledWith( + VALID_OFFER_ID, + VALID_USER_ID + ); + }); + + it('should call portfolioService.getUserPortfolio with userId', async () => { + await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`); + + expect(portfolioService.getUserPortfolio).toHaveBeenCalledWith(VALID_USER_ID); + }); + + it('should call reportService.generateEnhancedReport with correct params', async () => { + await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`); + + expect(reportService.generateEnhancedReport).toHaveBeenCalledWith( + VALID_OFFER_ID, + VALID_USER_ID, + mockOffer, + mockPortfolio + ); + }); + + it('should return 200 with cached report when analysis.enhanced already exists', async () => { + const offerWithEnhanced = { + ...mockOffer, + analysis: { + ...mockOffer.analysis, + enhanced: mockEnhancedReport, + }, + }; + offerService.findByIdAndUserId.mockResolvedValue(offerWithEnhanced); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data).toBeDefined(); + // Should NOT call generateEnhancedReport when cached + expect(reportService.generateEnhancedReport).not.toHaveBeenCalled(); + }); + + it('should work when user has no portfolio (null portfolio)', async () => { + portfolioService.getUserPortfolio.mockResolvedValue(null); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(201); + + expect(res.body.success).toBe(true); + expect(reportService.generateEnhancedReport).toHaveBeenCalledWith( + VALID_OFFER_ID, + VALID_USER_ID, + mockOffer, + null + ); + }); + }); + + // ── Ownership Validation ──────────────────────────────────────────────────── + + describe('Ownership Validation', () => { + it('should return 404 when offer does not exist', async () => { + const { NotFoundError } = require('../src/utils/errors'); + offerService.findByIdAndUserId.mockRejectedValue( + new NotFoundError(`Offer with ID '${VALID_OFFER_ID}' not found`) + ); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(404); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('NOT_FOUND'); + }); + + it('should return 403 when offer belongs to a different user', async () => { + const { ForbiddenError } = require('../src/utils/errors'); + offerService.findByIdAndUserId.mockRejectedValue( + new ForbiddenError('You do not have permission to access this offer') + ); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(403); + + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('FORBIDDEN'); + }); + }); + + // ── Error Handling ────────────────────────────────────────────────────────── + + describe('Error Handling', () => { + beforeEach(() => { + offerService.findByIdAndUserId.mockResolvedValue(mockOffer); + portfolioService.getUserPortfolio.mockResolvedValue(mockPortfolio); + }); + + it('should return 500 when reportService throws an unexpected error', async () => { + reportService.generateEnhancedReport.mockRejectedValue( + new Error('Unexpected internal error') + ); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(500); + + expect(res.body.success).toBe(false); + }); + + it('should return 500 when portfolioService throws an unexpected error', async () => { + portfolioService.getUserPortfolio.mockRejectedValue( + new Error('Database connection failed') + ); + + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(500); + + expect(res.body.success).toBe(false); + }); }); - it('should return 400 when portfolio is missing', async () => { - // Mock auth + paid user - verifyAccessToken.mockReturnValue({ id: 'user-456' }); - - const mockUserDoc = { - exists: true, - id: 'user-456', - data: () => ({ - id: 'user-456', - email: 'test@example.com', - verified: true, - paidAnalyses: true, - }), - }; - - db._mockDoc.get.mockResolvedValue(mockUserDoc); - - const res = await request(app) - .post('/api/v1/analysis/enhanced/offer-123') - .set('Authorization', 'Bearer valid-token') - .send({}); - - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); + // ── Response Shape ────────────────────────────────────────────────────────── + + describe('Response Shape', () => { + beforeEach(() => { + offerService.findByIdAndUserId.mockResolvedValue(mockOffer); + portfolioService.getUserPortfolio.mockResolvedValue(mockPortfolio); + reportService.generateEnhancedReport.mockResolvedValue(mockEnhancedReport); + }); + + it('should return correct response structure', async () => { + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(201); + + expect(res.body).toMatchObject({ + success: true, + message: expect.any(String), + data: { + tricks: expect.any(Array), + negotiationScript: expect.any(String), + insights: expect.any(Array), + comparison: expect.any(Object), + generatedAt: expect.any(String), + generatedBy: expect.any(String), + processingTimeMs: expect.any(Number), + }, + }); + }); + + it('should include trick with required fields', async () => { + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(201); + + const trick = res.body.data.tricks[0]; + expect(trick).toHaveProperty('nameHe'); + expect(trick).toHaveProperty('nameEn'); + expect(trick).toHaveProperty('descriptionHe'); + expect(trick).toHaveProperty('descriptionEn'); + expect(trick).toHaveProperty('applicability'); + expect(trick).toHaveProperty('riskLevel'); + }); + + it('should include insight with required fields', async () => { + const res = await request(app) + .post(`/api/v1/analysis/${VALID_OFFER_ID}/enhanced`) + .set('Authorization', `Bearer ${VALID_TOKEN}`) + .expect(201); + + const insight = res.body.data.insights[0]; + expect(insight).toHaveProperty('titleHe'); + expect(insight).toHaveProperty('titleEn'); + expect(insight).toHaveProperty('bodyHe'); + expect(insight).toHaveProperty('bodyEn'); + expect(insight).toHaveProperty('icon'); + }); }); }); diff --git a/__tests__/analysisValidator.test.js b/__tests__/analysisValidator.test.js index be5e1b9..d706938 100644 --- a/__tests__/analysisValidator.test.js +++ b/__tests__/analysisValidator.test.js @@ -1,151 +1,53 @@ +'use strict'; + /** - * Analysis Validator Tests - * - * Tests for the Joi validation schemas used by the enhanced analysis endpoint. + * Unit tests for analysisValidator. */ -'use strict'; - -const { enhancedAnalysisSchema } = require('../src/validators/analysisValidator'); +const { offerIdParamSchema } = require('../src/validators/analysisValidator'); -const validBody = { - portfolio: { - id: 'market_standard', - name: 'Market Standard', - nameHe: 'תיק שוק סטנדרטי', - termYears: 30, - tracks: [ - { type: 'fixed', percentage: 34, rate: 4.75 }, - { type: 'prime', percentage: 33, rate: 5.9 }, - { type: 'cpi', percentage: 33, rate: 3.2 }, - ], - monthlyRepayment: 5200, - totalCost: 1872000, - totalInterest: 672000, - }, -}; +describe('offerIdParamSchema', () => { + const validate = (offerId) => + offerIdParamSchema.validate({ offerId }, { abortEarly: false }); -describe('enhancedAnalysisSchema', () => { - it('should accept a valid request body', () => { - const { error } = enhancedAnalysisSchema.validate(validBody); + it('should accept a valid alphanumeric offerId', () => { + const { error } = validate('offer123abc'); expect(error).toBeUndefined(); }); - it('should reject missing portfolio', () => { - const { error } = enhancedAnalysisSchema.validate({}); - expect(error).toBeDefined(); - expect(error.details[0].path).toContain('portfolio'); - }); - - it('should reject portfolio without id', () => { - const body = { - portfolio: { ...validBody.portfolio, id: '' }, - }; - const { error } = enhancedAnalysisSchema.validate(body); - expect(error).toBeDefined(); - }); - - it('should reject portfolio without name', () => { - const body = { - portfolio: { ...validBody.portfolio, name: '' }, - }; - const { error } = enhancedAnalysisSchema.validate(body); - expect(error).toBeDefined(); - }); - - it('should reject portfolio with invalid termYears', () => { - const body = { - portfolio: { ...validBody.portfolio, termYears: 0 }, - }; - const { error } = enhancedAnalysisSchema.validate(body); - expect(error).toBeDefined(); - }); - - it('should reject portfolio with termYears > 40', () => { - const body = { - portfolio: { ...validBody.portfolio, termYears: 50 }, - }; - const { error } = enhancedAnalysisSchema.validate(body); - expect(error).toBeDefined(); - }); - - it('should reject portfolio without tracks', () => { - const body = { - portfolio: { ...validBody.portfolio, tracks: [] }, - }; - const { error } = enhancedAnalysisSchema.validate(body); - expect(error).toBeDefined(); - }); - - it('should reject track with invalid type', () => { - const body = { - portfolio: { - ...validBody.portfolio, - tracks: [{ type: 'invalid', percentage: 100, rate: 4.5 }], - }, - }; - const { error } = enhancedAnalysisSchema.validate(body); - expect(error).toBeDefined(); + it('should accept offerId with hyphens and underscores', () => { + const { error } = validate('offer-123_abc'); + expect(error).toBeUndefined(); }); - it('should reject track with percentage > 100', () => { - const body = { - portfolio: { - ...validBody.portfolio, - tracks: [{ type: 'fixed', percentage: 150, rate: 4.5 }], - }, - }; - const { error } = enhancedAnalysisSchema.validate(body); + it('should reject an empty offerId', () => { + const { error } = validate(''); expect(error).toBeDefined(); }); - it('should reject track with negative rate', () => { - const body = { - portfolio: { - ...validBody.portfolio, - tracks: [{ type: 'fixed', percentage: 100, rate: -1 }], - }, - }; - const { error } = enhancedAnalysisSchema.validate(body); + it('should reject offerId with special characters', () => { + const { error } = validate('offer!@#$%'); expect(error).toBeDefined(); + expect(error.details[0].message).toContain('invalid characters'); }); - it('should reject portfolio with negative monthlyRepayment', () => { - const body = { - portfolio: { ...validBody.portfolio, monthlyRepayment: -100 }, - }; - const { error } = enhancedAnalysisSchema.validate(body); + it('should reject offerId with spaces', () => { + const { error } = validate('offer 123'); expect(error).toBeDefined(); }); - it('should reject portfolio with negative totalInterest', () => { - const body = { - portfolio: { ...validBody.portfolio, totalInterest: -1 }, - }; - const { error } = enhancedAnalysisSchema.validate(body); + it('should reject offerId longer than 128 characters', () => { + const { error } = validate('a'.repeat(129)); expect(error).toBeDefined(); }); - it('should accept portfolio with optional fields', () => { - const body = { - portfolio: { - ...validBody.portfolio, - description: 'A test portfolio', - interestSavings: 50000, - fitnessScore: 85, - recommended: true, - }, - }; - const { error } = enhancedAnalysisSchema.validate(body); + it('should accept offerId of exactly 128 characters', () => { + const { error } = validate('a'.repeat(128)); expect(error).toBeUndefined(); }); - it('should accept optional portfolioId field', () => { - const body = { - ...validBody, - portfolioId: 'market_standard', - }; - const { error } = enhancedAnalysisSchema.validate(body); - expect(error).toBeUndefined(); + it('should reject missing offerId', () => { + const { error } = offerIdParamSchema.validate({}); + expect(error).toBeDefined(); }); }); diff --git a/__tests__/paidAccess.test.js b/__tests__/paidAccess.test.js index 3959cbf..c94c99c 100644 --- a/__tests__/paidAccess.test.js +++ b/__tests__/paidAccess.test.js @@ -1,124 +1,65 @@ -/** - * Paid Access Middleware Tests - * - * Tests for the requirePaidAccess middleware that checks - * whether a user has paid for enhanced analysis features. - */ - 'use strict'; -jest.mock('../src/config/firestore', () => { - const mockDoc = { - get: jest.fn(), - }; - const mockCollection = jest.fn(() => ({ - doc: jest.fn(() => mockDoc), - })); - const mock = { - collection: mockCollection, - _mockDoc: mockDoc, - }; - return mock; -}); - -jest.mock('../src/utils/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); +/** + * Unit tests for the paidAccess middleware. + */ -const httpMocks = require('node-mocks-http'); -const { requirePaidAccess } = require('../src/middleware/paidAccess'); -const db = require('../src/config/firestore'); +const { paidAccess } = require('../src/middleware/paidAccess'); +const { ForbiddenError } = require('../src/utils/errors'); -describe('requirePaidAccess middleware', () => { - let req; - let res; - let next; +describe('paidAccess middleware', () => { + let req, res, next; beforeEach(() => { - jest.clearAllMocks(); - req = httpMocks.createRequest(); - res = httpMocks.createResponse(); - // Attach json method that supertest/express would provide - res.json = jest.fn().mockReturnValue(res); - res.status = jest.fn().mockReturnValue(res); + req = {}; + res = {}; next = jest.fn(); }); - it('should return 401 when req.user is missing', async () => { - await requirePaidAccess(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ success: false, message: 'Authentication required' }) - ); - expect(next).not.toHaveBeenCalled(); + it('should call next() when user has paidAnalyses = true', () => { + req.user = { uid: 'user123', paidAnalyses: true }; + paidAccess(req, res, next); + expect(next).toHaveBeenCalledWith(); + expect(next).toHaveBeenCalledTimes(1); }); - it('should return 401 when user document does not exist', async () => { - req.user = { id: 'user-123' }; - db._mockDoc.get.mockResolvedValue({ exists: false }); - - await requirePaidAccess(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); + it('should call next(ForbiddenError) when user has paidAnalyses = false', () => { + req.user = { uid: 'user123', paidAnalyses: false }; + paidAccess(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + const error = next.mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); + expect(error.statusCode).toBe(403); + expect(error.code).toBe('FORBIDDEN'); }); - it('should return 403 when user has not paid', async () => { - req.user = { id: 'user-123' }; - db._mockDoc.get.mockResolvedValue({ - exists: true, - data: () => ({ paidAnalyses: false }), - }); - - await requirePaidAccess(req, res, next); - - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - errorCode: 'PAYMENT_REQUIRED', - }) - ); - expect(next).not.toHaveBeenCalled(); + it('should call next(ForbiddenError) when user has no paidAnalyses field', () => { + req.user = { uid: 'user123', email: 'test@example.com' }; + paidAccess(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + const error = next.mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); + expect(error.statusCode).toBe(403); }); - it('should return 403 when paidAnalyses is undefined', async () => { - req.user = { id: 'user-123' }; - db._mockDoc.get.mockResolvedValue({ - exists: true, - data: () => ({}), - }); - - await requirePaidAccess(req, res, next); - - expect(res.status).toHaveBeenCalledWith(403); - expect(next).not.toHaveBeenCalled(); + it('should call next(ForbiddenError) when user has paidAnalyses = null', () => { + req.user = { uid: 'user123', paidAnalyses: null }; + paidAccess(req, res, next); + const error = next.mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); }); - it('should call next() when user has paid', async () => { - req.user = { id: 'user-123' }; - db._mockDoc.get.mockResolvedValue({ - exists: true, - data: () => ({ paidAnalyses: true }), - }); - - await requirePaidAccess(req, res, next); - - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); + it('should call next(ForbiddenError) when req.user is undefined', () => { + req.user = undefined; + paidAccess(req, res, next); + const error = next.mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); }); - it('should return 500 on Firestore error', async () => { - req.user = { id: 'user-123' }; - db._mockDoc.get.mockRejectedValue(new Error('Firestore unavailable')); - - await requirePaidAccess(req, res, next); - - expect(res.status).toHaveBeenCalledWith(500); - expect(next).not.toHaveBeenCalled(); + it('should include a descriptive error message', () => { + req.user = { uid: 'user123', paidAnalyses: false }; + paidAccess(req, res, next); + const error = next.mock.calls[0][0]; + expect(error.message).toContain('paid'); }); }); diff --git a/__tests__/reportService.test.js b/__tests__/reportService.test.js index 7c43261..ffddf28 100644 --- a/__tests__/reportService.test.js +++ b/__tests__/reportService.test.js @@ -1,614 +1,332 @@ -/** - * Report Service Tests - * - * Tests for the enhanced OCR analysis report generation service. - * Covers comparison building, savings estimation, portfolio validation, - * rule-based report generation, and sanitization helpers. - */ - 'use strict'; -// Mock dependencies before requiring the module -jest.mock('../src/config/firestore', () => { - const mockCollection = jest.fn(() => ({ - doc: jest.fn(() => ({ - get: jest.fn().mockResolvedValue({ exists: true, data: () => ({}) }), - set: jest.fn().mockResolvedValue(undefined), - update: jest.fn().mockResolvedValue(undefined), - })), - add: jest.fn().mockResolvedValue({ id: 'mock-id' }), - where: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - get: jest.fn().mockResolvedValue({ empty: true, docs: [], size: 0 }), - })); - return { - collection: mockCollection, - batch: jest.fn(() => ({ - set: jest.fn(), - commit: jest.fn().mockResolvedValue(undefined), - })), - }; -}); +/** + * Unit tests for reportService. + */ -jest.mock('../src/services/offerService', () => ({ - findByIdAndUserId: jest.fn(), - updateOffer: jest.fn().mockResolvedValue({}), -})); - -jest.mock('../src/services/ratesService', () => ({ - getCurrentAverages: jest.fn().mockResolvedValue({ - fixed: 4.65, - cpi: 3.15, - prime: 6.05, - variable: 4.95, - }), -})); - -jest.mock('../src/utils/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const reportService = require('../src/services/reportService'); -const offerService = require('../src/services/offerService'); -const ratesService = require('../src/services/ratesService'); - -// ── Test Data ───────────────────────────────────────────────────────────────── +jest.mock('../src/services/aiService'); +jest.mock('../src/services/offerService'); + +const { callGPT } = require('../src/services/aiService'); +const { updateEnhancedAnalysis } = require('../src/services/offerService'); +const { + buildComparison, + sanitizeTrick, + sanitizeInsight, + generateFallbackReport, + generateEnhancedReport, +} = require('../src/services/reportService'); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockOffer = { + id: 'offer123', + userId: 'user456', + bankName: 'Bank Hapoalim', + status: 'analyzed', + analysis: { + terms: { + loanAmount: 1500000, + termYears: 25, + interestRate: 5.2, + tracks: [ + { type: 'fixed', name: 'קל"צ', rate: 5.2 }, + ], + }, + }, +}; const mockPortfolio = { - id: 'market_standard', - type: 'market_standard', - name: 'Market Standard', - nameHe: 'תיק שוק סטנדרטי', - termYears: 30, + id: 'portfolio789', + userId: 'user456', + averageRate: 4.75, tracks: [ - { type: 'fixed', percentage: 34, rate: 4.75, rateDisplay: '4.75%', amount: 408000 }, - { type: 'prime', percentage: 33, rate: 5.9, rateDisplay: 'P-0.15%', amount: 396000 }, - { type: 'cpi', percentage: 33, rate: 3.2, rateDisplay: '3.20% + מדד', amount: 396000 }, + { type: 'fixed', name: 'קל"צ', rate: 4.75, amount: 750000 }, + { type: 'prime', name: 'פריים', rate: 4.5, amount: 750000 }, ], - monthlyRepayment: 5200, - totalCost: 1872000, - totalInterest: 672000, -}; - -const mockAnalyzedOffer = { - id: 'offer-123', - userId: 'user-456', - originalFile: { url: 'https://example.com/file.pdf', mimetype: 'application/pdf' }, - extractedData: { - bank: 'בנק לאומי', - amount: 1200000, - rate: 5.2, - term: 25, - }, - analysis: { - recommendedRate: 4.5, - savings: 48000, - aiReasoning: 'Mock analysis reasoning', - }, - status: 'analyzed', - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', }; -const mockCurrentRates = { - fixed: 4.65, - cpi: 3.15, - prime: 6.05, - variable: 4.95, +const mockAIResponse = { + tricks: [ + { + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + descriptionHe: 'תיאור בעברית', + descriptionEn: 'Description in English', + applicability: 'high', + riskLevel: 'medium', + potentialSavings: 22000, + }, + ], + negotiationScript: 'שלום, שמי [שם]. אני מעוניין/ת במשכנתא...', + insights: [ + { + titleHe: 'ניתוח ריבית', + titleEn: 'Rate Analysis', + bodyHe: 'גוף בעברית', + bodyEn: 'Body in English', + icon: 'trending-down', + }, + ], }; -// ── Tests ───────────────────────────────────────────────────────────────────── +// ─── buildComparison ────────────────────────────────────────────────────────── -describe('reportService', () => { - beforeEach(() => { - jest.clearAllMocks(); +describe('buildComparison', () => { + it('should calculate rateDelta correctly', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + expect(comparison.rateDelta).toBeCloseTo(0.45, 2); }); - // ── calculateWeightedRate ───────────────────────────────────────────────── - - describe('calculateWeightedRate', () => { - it('should calculate weighted average rate correctly', () => { - const tracks = [ - { type: 'fixed', percentage: 40, rate: 4.7 }, - { type: 'prime', percentage: 30, rate: 5.9 }, - { type: 'cpi', percentage: 30, rate: 3.2 }, - ]; - - const result = reportService.calculateWeightedRate(tracks); - // (4.7 * 0.4 + 5.9 * 0.3 + 3.2 * 0.3) = 1.88 + 1.77 + 0.96 = 4.61 - expect(result).toBeCloseTo(4.61, 1); - }); - - it('should return null for empty tracks', () => { - expect(reportService.calculateWeightedRate([])).toBeNull(); - expect(reportService.calculateWeightedRate(null)).toBeNull(); - }); - - it('should handle tracks with null rates', () => { - const tracks = [ - { type: 'fixed', percentage: 50, rate: 4.0 }, - { type: 'prime', percentage: 50, rate: null }, - ]; - const result = reportService.calculateWeightedRate(tracks); - expect(result).toBe(4.0); - }); + it('should calculate monthlySaving as a number', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + expect(typeof comparison.monthlySaving).toBe('number'); + expect(comparison.monthlySaving).toBeGreaterThan(0); }); - // ── calculatePMT ────────────────────────────────────────────────────────── - - describe('calculatePMT', () => { - it('should calculate monthly payment correctly', () => { - // ₪1,000,000 at 5% for 30 years - const monthly = reportService.calculatePMT(1000000, 0.05 / 12, 360); - expect(monthly).toBeCloseTo(5368.22, 0); - }); - - it('should handle zero interest rate', () => { - const monthly = reportService.calculatePMT(1200000, 0, 360); - expect(monthly).toBeCloseTo(3333.33, 0); - }); - - it('should return 0 for zero principal', () => { - expect(reportService.calculatePMT(0, 0.05 / 12, 360)).toBe(0); - }); - - it('should return 0 for zero months', () => { - expect(reportService.calculatePMT(1000000, 0.05 / 12, 0)).toBe(0); - }); + it('should calculate totalSaving as a number', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + expect(typeof comparison.totalSaving).toBe('number'); + expect(comparison.totalSaving).toBeGreaterThan(0); }); - // ── estimateSavings ─────────────────────────────────────────────────────── - - describe('estimateSavings', () => { - it('should estimate savings when bank rate is higher', () => { - const result = reportService.estimateSavings(1200000, 5.2, 4.5, 25); - expect(result.monthly).toBeGreaterThan(0); - expect(result.total).toBeGreaterThan(0); - expect(result.interest).toBeGreaterThan(0); - }); - - it('should return zero savings when bank rate is lower', () => { - const result = reportService.estimateSavings(1200000, 4.0, 4.5, 25); - expect(result.monthly).toBe(0); - expect(result.total).toBe(0); - }); - - it('should return nulls when data is missing', () => { - const result = reportService.estimateSavings(1200000, null, 4.5, 25); - expect(result.monthly).toBeNull(); - expect(result.total).toBeNull(); - expect(result.interest).toBeNull(); - }); - - it('should return nulls when loan amount is zero', () => { - const result = reportService.estimateSavings(0, 5.0, 4.5, 25); - expect(result.monthly).toBeNull(); - }); + it('should return null rateDelta when portfolio is null', () => { + const comparison = buildComparison(mockOffer, null); + expect(comparison.rateDelta).toBeNull(); + expect(comparison.monthlySaving).toBeNull(); + expect(comparison.totalSaving).toBeNull(); }); - // ── buildComparison ─────────────────────────────────────────────────────── - - describe('buildComparison', () => { - it('should build a complete comparison object', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - expect(comparison).toHaveProperty('bankOffer'); - expect(comparison).toHaveProperty('optimizedModel'); - expect(comparison).toHaveProperty('rateDifference'); - expect(comparison).toHaveProperty('potentialMonthlySavings'); - expect(comparison).toHaveProperty('potentialTotalSavings'); - expect(comparison).toHaveProperty('trackComparisons'); - expect(comparison).toHaveProperty('boiAverages'); - expect(comparison).toHaveProperty('verdict'); - - expect(comparison.bankOffer.bank).toBe('בנק לאומי'); - expect(comparison.bankOffer.rate).toBe(5.2); - expect(comparison.optimizedModel.name).toBe('Market Standard'); - expect(comparison.trackComparisons).toHaveLength(3); - }); - - it('should calculate positive rate difference when bank is more expensive', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - // Bank rate (5.2) should be higher than portfolio weighted rate - expect(comparison.rateDifference).toBeGreaterThan(0); - }); - - it('should set verdict based on rate difference', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - expect(['significantly_worse', 'slightly_worse', 'comparable', 'better_than_model']) - .toContain(comparison.verdict); - }); + it('should include loanAmount and termYears from offer', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + expect(comparison.loanAmount).toBe(1500000); + expect(comparison.termYears).toBe(25); + }); - it('should handle missing OCR data gracefully', () => { - const offerWithMissingData = { - ...mockAnalyzedOffer, - extractedData: { bank: '', amount: null, rate: null, term: null }, - }; + it('should build trackComparison array', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + expect(Array.isArray(comparison.trackComparison)).toBe(true); + expect(comparison.trackComparison).toHaveLength(1); + expect(comparison.trackComparison[0]).toHaveProperty('bankRate'); + expect(comparison.trackComparison[0]).toHaveProperty('portfolioRate'); + }); +}); - const comparison = reportService.buildComparison( - offerWithMissingData, - mockPortfolio, - mockCurrentRates - ); +// ─── sanitizeTrick ──────────────────────────────────────────────────────────── - expect(comparison.rateDifference).toBeNull(); - expect(comparison.verdict).toBe('insufficient_data'); +describe('sanitizeTrick', () => { + it('should return a valid trick object', () => { + const trick = sanitizeTrick(mockAIResponse.tricks[0]); + expect(trick).toMatchObject({ + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + applicability: 'high', + riskLevel: 'medium', + potentialSavings: 22000, }); }); - // ── buildTrackComparisons ───────────────────────────────────────────────── - - describe('buildTrackComparisons', () => { - it('should build comparisons for each portfolio track', () => { - const comparisons = reportService.buildTrackComparisons( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); + it('should return null for invalid input', () => { + expect(sanitizeTrick(null)).toBeNull(); + expect(sanitizeTrick('string')).toBeNull(); + expect(sanitizeTrick(42)).toBeNull(); + }); - expect(comparisons).toHaveLength(3); - expect(comparisons[0].trackType).toBe('fixed'); - expect(comparisons[1].trackType).toBe('prime'); - expect(comparisons[2].trackType).toBe('cpi'); - }); + it('should default applicability to medium for invalid values', () => { + const trick = sanitizeTrick({ ...mockAIResponse.tricks[0], applicability: 'invalid' }); + expect(trick.applicability).toBe('medium'); + }); - it('should include BOI comparison for each track', () => { - const comparisons = reportService.buildTrackComparisons( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - for (const comp of comparisons) { - expect(comp).toHaveProperty('boiAverage'); - expect(comp).toHaveProperty('vsBoi'); - expect(comp).toHaveProperty('vsBoiLabel'); - } - }); + it('should default riskLevel to medium for invalid values', () => { + const trick = sanitizeTrick({ ...mockAIResponse.tricks[0], riskLevel: 'extreme' }); + expect(trick.riskLevel).toBe('medium'); + }); - it('should include bank offer comparison when rate is available', () => { - const comparisons = reportService.buildTrackComparisons( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - for (const comp of comparisons) { - expect(comp).toHaveProperty('bankOfferRate', 5.2); - expect(comp).toHaveProperty('vsBank'); - expect(comp).toHaveProperty('vsBankLabel'); - } - }); + it('should truncate long strings', () => { + const longString = 'a'.repeat(300); + const trick = sanitizeTrick({ ...mockAIResponse.tricks[0], nameHe: longString }); + expect(trick.nameHe.length).toBeLessThanOrEqual(200); }); - // ── validatePortfolio ───────────────────────────────────────────────────── + it('should set potentialSavings to null for negative values', () => { + const trick = sanitizeTrick({ ...mockAIResponse.tricks[0], potentialSavings: -100 }); + expect(trick.potentialSavings).toBeNull(); + }); +}); - describe('validatePortfolio', () => { - it('should accept a valid portfolio', () => { - expect(() => reportService.validatePortfolio(mockPortfolio)).not.toThrow(); - }); +// ─── sanitizeInsight ────────────────────────────────────────────────────────── - it('should reject null portfolio', () => { - expect(() => reportService.validatePortfolio(null)).toThrow('Portfolio data is required'); +describe('sanitizeInsight', () => { + it('should return a valid insight object', () => { + const insight = sanitizeInsight(mockAIResponse.insights[0]); + expect(insight).toMatchObject({ + titleHe: 'ניתוח ריבית', + titleEn: 'Rate Analysis', + icon: 'trending-down', }); + }); - it('should reject portfolio without id', () => { - const invalid = { ...mockPortfolio, id: '' }; - expect(() => reportService.validatePortfolio(invalid)).toThrow('valid id'); - }); + it('should return null for invalid input', () => { + expect(sanitizeInsight(null)).toBeNull(); + expect(sanitizeInsight(undefined)).toBeNull(); + }); - it('should reject portfolio without tracks', () => { - const invalid = { ...mockPortfolio, tracks: [] }; - expect(() => reportService.validatePortfolio(invalid)).toThrow('at least one track'); - }); + it('should default icon to info for invalid icon values', () => { + const insight = sanitizeInsight({ ...mockAIResponse.insights[0], icon: 'invalid-icon' }); + expect(insight.icon).toBe('info'); + }); - it('should reject portfolio with invalid termYears', () => { - const invalid = { ...mockPortfolio, termYears: 0 }; - expect(() => reportService.validatePortfolio(invalid)).toThrow('valid termYears'); + it('should accept all valid icon values', () => { + const validIcons = ['trending-down', 'check-circle', 'target', 'calendar', 'shield', 'info']; + validIcons.forEach((icon) => { + const insight = sanitizeInsight({ ...mockAIResponse.insights[0], icon }); + expect(insight.icon).toBe(icon); }); + }); +}); - it('should reject portfolio with invalid monthlyRepayment', () => { - const invalid = { ...mockPortfolio, monthlyRepayment: -100 }; - expect(() => reportService.validatePortfolio(invalid)).toThrow('valid monthlyRepayment'); - }); +// ─── generateFallbackReport ─────────────────────────────────────────────────── - it('should reject portfolio with invalid totalCost', () => { - const invalid = { ...mockPortfolio, totalCost: 0 }; - expect(() => reportService.validatePortfolio(invalid)).toThrow('valid totalCost'); - }); +describe('generateFallbackReport', () => { + it('should return a complete report object', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + const report = generateFallbackReport(mockOffer, mockPortfolio, comparison); - it('should reject portfolio with invalid track percentage', () => { - const invalid = { - ...mockPortfolio, - tracks: [{ type: 'fixed', percentage: 0, rate: 4.5 }], - }; - expect(() => reportService.validatePortfolio(invalid)).toThrow('valid percentage'); - }); + expect(report).toHaveProperty('tricks'); + expect(report).toHaveProperty('negotiationScript'); + expect(report).toHaveProperty('insights'); + expect(report).toHaveProperty('comparison'); + expect(report).toHaveProperty('generatedAt'); + expect(report).toHaveProperty('generatedBy', 'rule-based-fallback'); + expect(report).toHaveProperty('processingTimeMs'); + }); - it('should reject portfolio with track percentages not summing to 100', () => { - const invalid = { - ...mockPortfolio, - tracks: [ - { type: 'fixed', percentage: 30, rate: 4.5 }, - { type: 'prime', percentage: 30, rate: 5.9 }, - ], - }; - expect(() => reportService.validatePortfolio(invalid)).toThrow('sum to 100%'); - }); + it('should include at least 3 tricks', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + const report = generateFallbackReport(mockOffer, mockPortfolio, comparison); + expect(report.tricks.length).toBeGreaterThanOrEqual(3); }); - // ── sanitizeTrick ───────────────────────────────────────────────────────── - - describe('sanitizeTrick', () => { - it('should sanitize a well-formed trick', () => { - const trick = { - nameHe: 'מסלול פיתיון', - nameEn: 'Enticement Track', - descriptionHe: 'תיאור בעברית', - descriptionEn: 'English description', - potentialSavings: 15000, - riskLevel: 'medium', - applicability: 'high', - }; - - const result = reportService.sanitizeTrick(trick); - expect(result).toEqual(trick); - }); + it('should include the Enticement Track trick', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + const report = generateFallbackReport(mockOffer, mockPortfolio, comparison); + const enticementTrick = report.tricks.find((t) => t.nameEn === 'Enticement Track'); + expect(enticementTrick).toBeDefined(); + }); - it('should handle missing fields with defaults', () => { - const result = reportService.sanitizeTrick({}); - expect(result.nameHe).toBe(''); - expect(result.nameEn).toBe(''); - expect(result.potentialSavings).toBeNull(); - expect(result.riskLevel).toBe('medium'); - expect(result.applicability).toBe('medium'); - }); + it('should include a non-empty negotiation script', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + const report = generateFallbackReport(mockOffer, mockPortfolio, comparison); + expect(report.negotiationScript.length).toBeGreaterThan(50); + }); - it('should reject invalid riskLevel values', () => { - const result = reportService.sanitizeTrick({ riskLevel: 'extreme' }); - expect(result.riskLevel).toBe('medium'); - }); + it('should include at least 3 insights', () => { + const comparison = buildComparison(mockOffer, mockPortfolio); + const report = generateFallbackReport(mockOffer, mockPortfolio, comparison); + expect(report.insights.length).toBeGreaterThanOrEqual(3); }); +}); - // ── sanitizeInsight ─────────────────────────────────────────────────────── +// ─── generateEnhancedReport ─────────────────────────────────────────────────── - describe('sanitizeInsight', () => { - it('should sanitize a well-formed insight', () => { - const insight = { - titleHe: 'כותרת', - titleEn: 'Title', - bodyHe: 'גוף', - bodyEn: 'Body', - icon: 'shield', - }; +describe('generateEnhancedReport', () => { + beforeEach(() => { + updateEnhancedAnalysis.mockResolvedValue(undefined); + }); - const result = reportService.sanitizeInsight(insight); - expect(result).toEqual(insight); - }); + afterEach(() => { + jest.clearAllMocks(); + }); - it('should handle missing fields with defaults', () => { - const result = reportService.sanitizeInsight({}); - expect(result.titleHe).toBe(''); - expect(result.icon).toBe('info'); - }); + it('should return AI-generated report when AI succeeds', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + + const report = await generateEnhancedReport( + 'offer123', + 'user456', + mockOffer, + mockPortfolio + ); + + expect(report.generatedBy).toBe('ai'); + expect(report.tricks).toHaveLength(1); + expect(report.negotiationScript).toBe('שלום, שמי [שם]. אני מעוניין/ת במשכנתא...'); + expect(updateEnhancedAnalysis).toHaveBeenCalledWith('offer123', expect.any(Object)); }); - // ── generateRuleBasedReport ─────────────────────────────────────────────── - - describe('generateRuleBasedReport', () => { - it('should generate a complete rule-based report', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - const report = reportService.generateRuleBasedReport( - mockAnalyzedOffer, - mockPortfolio, - comparison, - mockCurrentRates - ); - - expect(report).toHaveProperty('tricks'); - expect(report).toHaveProperty('negotiationScript'); - expect(report).toHaveProperty('insights'); - expect(report).toHaveProperty('summary'); - expect(report).toHaveProperty('summaryHe'); - }); + it('should fall back to rule-based report when AI fails', async () => { + callGPT.mockRejectedValue(new Error('OpenAI API error')); + process.env.OPENAI_API_KEY = 'test-key'; - it('should always include the Enticement Track trick', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - const report = reportService.generateRuleBasedReport( - mockAnalyzedOffer, - mockPortfolio, - comparison, - mockCurrentRates - ); - - const enticementTrick = report.tricks.find((t) => t.nameEn === 'Enticement Track'); - expect(enticementTrick).toBeDefined(); - expect(enticementTrick.nameHe).toBe('מסלול פיתיון'); - }); + const report = await generateEnhancedReport( + 'offer123', + 'user456', + mockOffer, + mockPortfolio + ); - it('should generate a Hebrew negotiation script', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - const report = reportService.generateRuleBasedReport( - mockAnalyzedOffer, - mockPortfolio, - comparison, - mockCurrentRates - ); - - expect(report.negotiationScript).toContain('שלום'); - expect(report.negotiationScript).toContain('בנק לאומי'); - expect(report.negotiationScript).toContain('בנק ישראל'); - }); + expect(report.generatedBy).toBe('rule-based-fallback'); + expect(report.tricks.length).toBeGreaterThan(0); + expect(updateEnhancedAnalysis).toHaveBeenCalled(); + }); - it('should include BOI rate matching trick when bank rate is higher', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - const report = reportService.generateRuleBasedReport( - mockAnalyzedOffer, - mockPortfolio, - comparison, - mockCurrentRates - ); - - const boiTrick = report.tricks.find((t) => t.nameEn === 'BOI Rate Matching'); - expect(boiTrick).toBeDefined(); - }); + it('should fall back to rule-based report when OPENAI_API_KEY is not set', async () => { + delete process.env.OPENAI_API_KEY; - it('should generate insights with Hebrew and English content', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - const report = reportService.generateRuleBasedReport( - mockAnalyzedOffer, - mockPortfolio, - comparison, - mockCurrentRates - ); - - expect(report.insights.length).toBeGreaterThanOrEqual(2); - for (const insight of report.insights) { - expect(insight).toHaveProperty('titleHe'); - expect(insight).toHaveProperty('titleEn'); - expect(insight).toHaveProperty('bodyHe'); - expect(insight).toHaveProperty('bodyEn'); - expect(insight).toHaveProperty('icon'); - } - }); + const report = await generateEnhancedReport( + 'offer123', + 'user456', + mockOffer, + mockPortfolio + ); - it('should limit tricks to 4', () => { - const comparison = reportService.buildComparison( - mockAnalyzedOffer, - mockPortfolio, - mockCurrentRates - ); - - const report = reportService.generateRuleBasedReport( - mockAnalyzedOffer, - mockPortfolio, - comparison, - mockCurrentRates - ); - - expect(report.tricks.length).toBeLessThanOrEqual(4); - }); + expect(report.generatedBy).toBe('rule-based-fallback'); + expect(callGPT).not.toHaveBeenCalled(); }); - // ── generateEnhancedReport (integration) ────────────────────────────────── - - describe('generateEnhancedReport', () => { - it('should throw 404 when offer is not found', async () => { - offerService.findByIdAndUserId.mockResolvedValue(null); - - await expect( - reportService.generateEnhancedReport('offer-123', 'user-456', mockPortfolio) - ).rejects.toThrow('Offer not found or access denied'); - }); + it('should still return report even if storing to Firestore fails', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + updateEnhancedAnalysis.mockRejectedValue(new Error('Firestore write failed')); - it('should throw 400 when offer is not analyzed', async () => { - offerService.findByIdAndUserId.mockResolvedValue({ - ...mockAnalyzedOffer, - status: 'pending', - }); + const report = await generateEnhancedReport( + 'offer123', + 'user456', + mockOffer, + mockPortfolio + ); - await expect( - reportService.generateEnhancedReport('offer-123', 'user-456', mockPortfolio) - ).rejects.toThrow('must be analyzed via OCR'); - }); + expect(report).toBeDefined(); + expect(report.tricks).toBeDefined(); + }); - it('should throw 400 for invalid portfolio', async () => { - offerService.findByIdAndUserId.mockResolvedValue(mockAnalyzedOffer); + it('should include processingTimeMs in the report', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; - await expect( - reportService.generateEnhancedReport('offer-123', 'user-456', null) - ).rejects.toThrow('Portfolio data is required'); - }); + const report = await generateEnhancedReport( + 'offer123', + 'user456', + mockOffer, + mockPortfolio + ); - it('should generate a complete report with rule-based fallback', async () => { - offerService.findByIdAndUserId.mockResolvedValue(mockAnalyzedOffer); - ratesService.getCurrentAverages.mockResolvedValue(mockCurrentRates); - - // OpenAI is not configured in tests, so it will fall back to rule-based - const report = await reportService.generateEnhancedReport( - 'offer-123', - 'user-456', - mockPortfolio - ); - - expect(report).toHaveProperty('offerId', 'offer-123'); - expect(report).toHaveProperty('portfolioId', 'market_standard'); - expect(report).toHaveProperty('comparison'); - expect(report).toHaveProperty('tricks'); - expect(report).toHaveProperty('negotiationScript'); - expect(report).toHaveProperty('insights'); - expect(report).toHaveProperty('summary'); - expect(report).toHaveProperty('summaryHe'); - expect(report).toHaveProperty('generatedAt'); - expect(report).toHaveProperty('processingTimeMs'); - - // Verify the report was stored - expect(offerService.updateOffer).toHaveBeenCalledWith( - 'offer-123', - expect.objectContaining({ - 'analysis.enhanced': expect.any(Object), - portfolioId: 'market_standard', - }) - ); - }); + expect(typeof report.processingTimeMs).toBe('number'); + expect(report.processingTimeMs).toBeGreaterThanOrEqual(0); + }); - it('should still return report even if storage fails', async () => { - offerService.findByIdAndUserId.mockResolvedValue(mockAnalyzedOffer); - offerService.updateOffer.mockRejectedValue(new Error('Firestore write failed')); - ratesService.getCurrentAverages.mockResolvedValue(mockCurrentRates); + it('should work with null portfolio', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; - const report = await reportService.generateEnhancedReport( - 'offer-123', - 'user-456', - mockPortfolio - ); + const report = await generateEnhancedReport( + 'offer123', + 'user456', + mockOffer, + null + ); - // Report should still be returned despite storage failure - expect(report).toHaveProperty('offerId', 'offer-123'); - expect(report).toHaveProperty('tricks'); - }); + expect(report).toBeDefined(); }); }); diff --git a/docs/API.md b/docs/API.md index 89322ff..e3317ec 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,1297 +1,193 @@ -# Morty Backend — API Reference +# Morty Backend API Documentation -> **Base URL:** `https://morty-backend-h9sb.onrender.com/api/v1` -> **Local dev:** `http://localhost:5000/api/v1` -> Configurable via the `VITE_API_URL` environment variable on the frontend. +## Base URL ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Authentication](#authentication) -3. [Response Envelope](#response-envelope) -4. [Error Codes](#error-codes) -5. [Rate Limiting](#rate-limiting) -6. [Data Shapes](#data-shapes) -7. [Endpoints](#endpoints) - - [Auth](#auth-endpoints) - - [Profile](#profile-endpoints) - - [Offers](#offers-endpoints) - - [Analysis](#analysis-endpoints) - - [Dashboard](#dashboard-endpoints) - - [Health](#health-endpoint) -8. [Migration Notes (MongoDB → Firestore)](#migration-notes) -9. [Frontend Integration Guide](#frontend-integration-guide) - ---- - -## Overview - -Morty is an AI-powered mortgage analysis platform. The backend exposes a REST API -built with **Node.js 20 / Express 4**, backed by **Google Cloud Firestore** (via -`firebase-admin` 12). All endpoints return JSON. - -| Property | Value | -|----------|-------| -| Protocol | HTTPS (HTTP in local dev) | -| Format | JSON (`Content-Type: application/json`) | -| Auth | JWT Bearer token (access token, 15 min expiry) | -| Versioning | URL prefix `/api/v1` | - ---- - -## Authentication - -Protected endpoints require a valid **JWT access token** in the `Authorization` header: - -``` -Authorization: Bearer -``` - -### Token Lifecycle - -| Token | Expiry | Storage recommendation | -|-------|--------|------------------------| -| Access token | 15 minutes | Memory / React state | -| Refresh token | 7 days | `localStorage` (or `httpOnly` cookie) | - -### Token Refresh Flow - -1. Make an API request → receive `401 Unauthorized` -2. Call `POST /auth/refresh` with the stored `refreshToken` -3. Store the new `token` and `refreshToken` returned -4. Retry the original request with the new access token - -The backend implements **refresh token rotation**: each call to `/auth/refresh` -invalidates the old refresh token and issues a new one. - ---- - -## Response Envelope - -All responses (success and error) use a consistent JSON envelope: - -### Success - -```json -{ - "success": true, - "data": { ... }, - "message": "Human-readable description" -} -``` - -> `data` may be `null` when there is nothing to return (e.g., logout, delete). - -### Error - -```json -{ - "success": false, - "message": "Human-readable error description" -} -``` - -### Validation Error (422) - -```json -{ - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Validation failed", - "details": [ - { "field": "email", "message": "\"email\" must be a valid email" } - ], - "timestamp": "2026-04-03T02:16:00.000Z" - } -} -``` - ---- - -## Error Codes - -| HTTP Status | `error.code` / context | Meaning | -|-------------|------------------------|---------| -| 400 | — | Bad request (missing required field, invalid param) | -| 401 | `INVALID_CREDENTIALS` | Wrong email or password | -| 401 | `GOOGLE_ACCOUNT` | Account uses Google sign-in; email/password login rejected | -| 401 | `INVALID_FIREBASE_TOKEN` | Firebase ID token is expired, malformed, or invalid | -| 401 | `INVALID_REFRESH_TOKEN` | Refresh token is expired or malformed | -| 401 | `REFRESH_TOKEN_MISMATCH` | Refresh token does not match stored value | -| 401 | `UNAUTHORIZED` | Missing or invalid access token | -| 403 | `FORBIDDEN` | Authenticated but not authorised for this resource | -| 404 | — | Resource not found | -| 409 | `CONFLICT_ERROR` | Email already registered or linked to a different Google account | -| 422 | `VALIDATION_ERROR` | Request body failed Joi validation | -| 422 | `MISSING_EMAIL` | Firebase token verified but contains no email claim | -| 429 | — | Rate limit exceeded | -| 500 | — | Internal server error | -| 500 | `GOOGLE_AUTH_ERROR` | Unexpected server-side failure during Google sign-in | -| 502 | — | Upstream service error (e.g., Cloudinary upload failed) | - ---- - -## Rate Limiting - -| Endpoint group | Window | Max requests | -|----------------|--------|--------------| -| `/auth/*` | 15 min | 20 | -| All other `/api/v1/*` | 15 min | 100 | - -Rate limit headers are returned on every response: -- `RateLimit-Limit` -- `RateLimit-Remaining` -- `RateLimit-Reset` - ---- - -## Data Shapes - -### UserShape - -Returned by auth endpoints and `GET /auth/me`. - -```ts -interface UserShape { - id: string; // Firestore document ID (string, NOT ObjectId) - email: string; // Lowercase email address - phone: string; // Phone number (default: '') - verified: boolean; // Email verification status (default: false) - createdAt: string; // ISO 8601 timestamp - updatedAt: string; // ISO 8601 timestamp -} -``` - -> **Migration note:** `id` replaces the legacy `_id` (MongoDB ObjectId). Always -> use `user.id` — never `user._id`. - -### FinancialShape - -Returned by `/profile` endpoints. - -```ts -interface FinancialShape { - id: string; // == userId (Firestore doc ID) - userId: string; // Owner's Firestore user ID - income: number; // Monthly income (>= 0) - additionalIncome: number; // Additional monthly income (default: 0) - expenses: { - housing: number; // Housing costs (default: 0) - loans: number; // Loan repayments (default: 0) - other: number; // Other expenses (default: 0) - }; - assets: { - savings: number; // Savings balance (default: 0) - investments: number; // Investment value (default: 0) - }; - debts: Array<{ - type: string; // Debt type description - amount: number; // Debt amount (>= 0) - }>; - updatedAt: string; // ISO 8601 timestamp -} -``` - -### OfferShape - -Returned by `/offers` and `/analysis` endpoints. - -```ts -interface OfferShape { - id: string; // Firestore document ID (string) - userId: string; // Owner's Firestore user ID - originalFile: { - url: string; // Cloudinary secure URL - mimetype: string; // e.g., 'application/pdf', 'image/jpeg' - }; - extractedData: { - bank: string; // Bank name (default: '') - amount: number | null; // Loan amount in ILS - rate: number | null; // Interest rate (%) - term: number | null; // Loan term in months - }; - analysis: { - recommendedRate: number | null; // AI-recommended rate (%) - savings: number | null; // Estimated savings in ILS - aiReasoning: string; // AI explanation (default: '') - }; - status: 'pending' | 'analyzed' | 'error'; - createdAt: string; // ISO 8601 timestamp - updatedAt: string; // ISO 8601 timestamp -} -``` - -> **Note:** `analysis` fields may be `null` when `status === 'pending'`. -> Always use optional chaining: `offer.analysis?.savings ?? 0`. - -### DashboardShape - -Returned by `GET /dashboard`. - -```ts -interface DashboardShape { - financials: FinancialShape | null; // null if profile not set up yet - recentOffers: OfferShape[]; // Up to 5 most recent offers - stats: { - totalOffers: number; // Total offer count - savingsTotal: number; // Sum of all analysis.savings - pending: number; // Count of pending offers - analyzed: number; // Count of analyzed offers - error: number; // Count of errored offers - }; -} -``` - -### PaginationMeta - -Included in paginated list responses. - -```ts -interface PaginationMeta { - page: number; // Current page (1-based) - limit: number; // Items per page - total: number; // Total item count - pages: number; // Total page count -} -``` - ---- - -## Endpoints - -### Auth Endpoints - -#### `POST /auth/register` - -Create a new user account and receive JWT tokens. - -**Access:** Public -**Rate limit:** Auth limiter (20 req / 15 min) - -**Request body:** - -```json -{ - "email": "user@example.com", - "password": "securepassword123", - "phone": "050-1234567" -} -``` - -| Field | Type | Required | Validation | -|-------|------|----------|------------| -| `email` | string | ✅ | Valid email, lowercased | -| `password` | string | ✅ | Min 8 characters | -| `phone` | string | ❌ | Israeli format: `+972XXXXXXXXX` or `0XXXXXXXXX` | - -**Response: `201 Created`** - -```json -{ - "success": true, - "data": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "user": { - "id": "firestore-uid-abc123", - "email": "user@example.com", - "phone": "050-1234567", - "verified": false - } - }, - "message": "User registered successfully" -} -``` - -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 409 | Email already registered | -| 422 | Validation failed (invalid email, short password, etc.) | -| 500 | Internal server error | - ---- - -#### `POST /auth/login` - -Authenticate with email and password. - -**Access:** Public -**Rate limit:** Auth limiter (20 req / 15 min) - -**Request body:** - -```json -{ - "email": "user@example.com", - "password": "securepassword123" -} -``` - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "user": { - "id": "firestore-uid-abc123", - "email": "user@example.com", - "phone": "050-1234567", - "verified": false - } - }, - "message": "Login successful" -} -``` - -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 401 | Invalid email or password | -| 401 | Account uses Google sign-in (`GOOGLE_ACCOUNT`) | -| 422 | Validation failed | -| 500 | Internal server error | - ---- - -#### `POST /auth/refresh` - -Rotate the refresh token and receive a new access token. - -**Access:** Public -**Rate limit:** Auth limiter (20 req / 15 min) - -**Request body:** - -```json -{ - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} ``` - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - }, - "message": "Token refreshed successfully" -} -``` - -> **Important:** Both the old `token` and `refreshToken` are invalidated. Store -> the new values immediately. - -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 401 | Refresh token expired, malformed, or does not match stored value | -| 422 | Missing `refreshToken` field | -| 500 | Internal server error | - ---- - -#### `POST /auth/logout` - -Invalidate the refresh token (server-side logout). - -**Access:** Public (optionally authenticated) -**Rate limit:** Auth limiter (20 req / 15 min) - -**Request body (optional):** - -```json -{ - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} +https://api.morty.app/api/v1 ``` -> If `refreshToken` is omitted and the request is authenticated (valid -> `Authorization` header), the token is cleared by `userId` instead. +## Authentication -**Response: `200 OK`** +All protected endpoints require a Firebase ID token in the `Authorization` header: -```json -{ - "success": true, - "data": null, - "message": "Logged out successfully" -} ``` - ---- - -#### `GET /auth/me` - -Get the currently authenticated user's public profile. - -**Access:** 🔒 Protected -**Rate limit:** API limiter (100 req / 15 min) - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { - "user": { - "id": "firestore-uid-abc123", - "email": "user@example.com", - "phone": "050-1234567", - "verified": false, - "createdAt": "2026-04-03T02:16:00.000Z", - "updatedAt": "2026-04-03T02:16:00.000Z" - } - }, - "message": "User profile retrieved" -} +Authorization: Bearer ``` -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 401 | Missing or invalid access token | -| 404 | User not found (token valid but user deleted) | -| 500 | Internal server error | - --- -#### `POST /auth/google` - -Verify a Firebase ID token obtained from Google sign-in on the client and -issue custom JWT tokens. This endpoint is the backend counterpart to the -frontend `signInWithPopup(GoogleAuthProvider)` flow. +## Analysis Endpoints -**Access:** Public -**Rate limit:** Auth limiter (20 req / 15 min) +### POST /api/v1/analysis/:offerId/enhanced -**Flow:** -1. Frontend calls `firebase.auth().signInWithPopup(GoogleAuthProvider)` -2. Frontend calls `firebaseUser.getIdToken()` to obtain the Firebase ID token -3. Frontend sends `POST /auth/google { idToken }` to this endpoint -4. Backend verifies the token via Firebase Admin SDK (`admin.auth().verifyIdToken()`) -5. Backend finds or creates the Firestore user document (handles account linking) -6. Backend issues custom access + refresh tokens and returns the standard auth payload +Generate an AI-powered enhanced mortgage analysis report for a paid user. -**Request body:** +**Authentication**: Required (Firebase ID token) +**Authorization**: Requires `paidAnalyses = true` on user document +**Rate Limit**: 5 requests per minute per user -```json -{ - "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." -} -``` +#### URL Parameters -| Field | Type | Required | Validation | -|-------|------|----------|------------| -| `idToken` | string | ✅ | Non-empty Firebase ID token from `firebaseUser.getIdToken()` | +| Parameter | Type | Required | Description | +|-----------|--------|----------|--------------------------------| +| offerId | string | Yes | Firestore document ID of offer | -**Response: `200 OK`** +#### Request Headers -```json -{ - "success": true, - "data": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "user": { - "id": "firestore-doc-id-google", - "email": "googleuser@gmail.com", - "phone": "", - "verified": true - } - }, - "message": "Google sign-in successful" -} ``` - -> The response shape is **identical** to `POST /auth/login` — the frontend -> `authService.googleLogin()` can use the same token storage and `AUTH_SUCCESS` -> dispatch as the regular login flow. - -**Account linking behaviour:** - -| Scenario | Behaviour | -|----------|-----------| -| Returning Google user (firebaseUid match) | Fast path: update `updatedAt`, return existing user | -| Existing email/password user + first Google sign-in | Link: add `firebaseUid` to existing document | -| Brand-new Google user | Create passwordless document (`password: null`) | -| Email already linked to a **different** Google account | `409 CONFLICT_ERROR` | -| Google-only user attempts email/password login | `401 GOOGLE_ACCOUNT` (from `POST /auth/login`) | - -**Error responses:** - -| Status | `error.code` | Condition | -|--------|-------------|----------| -| 401 | `INVALID_FIREBASE_TOKEN` | Token expired, malformed, wrong audience, or tampered | -| 409 | `CONFLICT_ERROR` | Email already linked to a different Google account | -| 422 | `VALIDATION_ERROR` | `idToken` field missing or empty | -| 422 | `MISSING_EMAIL` | Firebase token verified but contains no email claim | -| 500 | `GOOGLE_AUTH_ERROR` | Unexpected server-side failure | - -**Frontend integration example:** - -```js -// src/services/authService.js -import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; -import api from './api'; - -export const googleLogin = async () => { - const auth = getAuth(); - const provider = new GoogleAuthProvider(); - - // Step 1: Firebase popup sign-in - const result = await signInWithPopup(auth, provider); - const idToken = await result.user.getIdToken(); - - // Step 2: Exchange Firebase ID token for custom JWTs - const { data } = await api.post('/auth/google', { idToken }); - - // data.data has the same shape as login: { token, refreshToken, user } - return data.data; -}; +Authorization: Bearer +Content-Type: application/json ``` ---- - -### Profile Endpoints - -All profile endpoints require authentication. - -#### `GET /profile` - -Retrieve the authenticated user's financial profile. - -**Access:** 🔒 Protected - -**Response: `200 OK`** (profile exists) +#### Success Response (201 — First Generation) ```json { "success": true, + "message": "Enhanced report generated successfully", "data": { - "id": "firestore-uid-abc123", - "userId": "firestore-uid-abc123", - "income": 15000, - "additionalIncome": 2000, - "expenses": { - "housing": 4000, - "loans": 1500, - "other": 800 - }, - "assets": { - "savings": 200000, - "investments": 50000 - }, - "debts": [ - { "type": "רכב", "amount": 80000 } + "tricks": [ + { + "nameHe": "מסלול פיתיון", + "nameEn": "Enticement Track", + "descriptionHe": "קחו מסלול בריבית גבוהה...", + "descriptionEn": "Take a high-interest track...", + "applicability": "high", + "riskLevel": "medium", + "potentialSavings": 22000 + } ], - "updatedAt": "2026-04-03T02:16:00.000Z" - }, - "message": "Financial profile retrieved" -} -``` - -**Response: `200 OK`** (no profile yet) - -```json -{ - "success": true, - "data": null, - "message": "No financial profile found" -} -``` - -> **Frontend note:** Always handle `data === null` — show an empty form or -> prompt the user to fill in their profile. - ---- - -#### `PUT /profile` - -Create or fully replace the authenticated user's financial profile (upsert). - -**Access:** 🔒 Protected - -**Request body** (all fields optional; missing fields default to `0` / `[]`): - -```json -{ - "income": 15000, - "additionalIncome": 2000, - "expenses": { - "housing": 4000, - "loans": 1500, - "other": 800 - }, - "assets": { - "savings": 200000, - "investments": 50000 - }, - "debts": [ - { "type": "רכב", "amount": 80000 } - ] -} -``` - -| Field | Type | Required | Validation | -|-------|------|----------|------------| -| `income` | number | ❌ | >= 0, default 0 | -| `additionalIncome` | number | ❌ | >= 0, default 0 | -| `expenses.housing` | number | ❌ | >= 0, default 0 | -| `expenses.loans` | number | ❌ | >= 0, default 0 | -| `expenses.other` | number | ❌ | >= 0, default 0 | -| `assets.savings` | number | ❌ | >= 0, default 0 | -| `assets.investments` | number | ❌ | >= 0, default 0 | -| `debts` | array | ❌ | Max 20 items, each `{ type: string, amount: number }` | - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { /* FinancialShape */ }, - "message": "Financial profile updated successfully" -} -``` - ---- - -#### `PATCH /profile` - -Partially update specific fields of the financial profile. -Only the provided fields are written; existing fields are preserved. - -**Access:** 🔒 Protected - -**Request body** (at least one field required): - -```json -{ - "income": 18000 -} -``` - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { /* FinancialShape with updated fields */ }, - "message": "Financial profile partially updated" -} -``` - -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 400 | Empty request body | -| 422 | Validation failed (negative number, invalid debt item, etc.) | - ---- - -### Offers Endpoints - -All offers endpoints require authentication. - -#### `POST /offers` - -Upload a mortgage offer file. The file is stored in Cloudinary and AI analysis -is triggered asynchronously. - -**Access:** 🔒 Protected -**Content-Type:** `multipart/form-data` - -**Form fields:** - -| Field | Type | Required | Notes | -|-------|------|----------|-------| -| `file` | File | ✅ | PDF, PNG, or JPG; max 10 MB | -| `bankName` | string | ❌ | Optional bank name hint for AI | - -**Response: `201 Created`** - -```json -{ - "success": true, - "data": { - "id": "offer-id-xyz789", - "status": "pending" + "negotiationScript": "שלום, שמי [שם]. אני מעוניין/ת במשכנתא...", + "insights": [ + { + "titleHe": "ניתוח הריבית", + "titleEn": "Rate Analysis", + "bodyHe": "הריבית המוצעת גבוהה מהממוצע...", + "bodyEn": "The offered rate is above average...", + "icon": "trending-down" + } + ], + "comparison": { + "rateDelta": 0.45, + "monthlySaving": 412, + "totalSaving": 123600, + "loanAmount": 1500000, + "termYears": 25, + "bankRate": 5.2, + "portfolioRate": 4.75, + "trackComparison": [ + { + "name": "קל\"צ", + "bankRate": 5.2, + "portfolioRate": 4.75, + "delta": 0.45 + } + ] + }, + "generatedAt": "2026-05-07T12:00:00.000Z", + "generatedBy": "ai", + "processingTimeMs": 1500 } } ``` -> **Frontend note:** After upload, poll `GET /offers/:id` or `GET /offers` to -> check when `status` changes from `'pending'` to `'analyzed'` or `'error'`. - -**Error responses:** +#### Success Response (200 — Cached Report) -| Status | Condition | -|--------|-----------| -| 400 | No file uploaded | -| 502 | Cloudinary upload failed | -| 500 | Internal server error | - ---- - -#### `GET /offers` - -List all offers for the authenticated user, sorted by `createdAt` descending. - -**Access:** 🔒 Protected - -**Query parameters:** - -| Param | Type | Default | Max | Description | -|-------|------|---------|-----|-------------| -| `page` | number | 1 | — | Page number (1-based) | -| `limit` | number | 10 | 50 | Items per page | - -**Response: `200 OK`** +Same structure as above, returned when the enhanced report already exists. ```json { "success": true, - "data": [ - { - "id": "offer-id-xyz789", - "userId": "firestore-uid-abc123", - "originalFile": { - "url": "https://res.cloudinary.com/morty/raw/upload/v1234/morty/offers/file.pdf", - "mimetype": "application/pdf" - }, - "extractedData": { - "bank": "הפועלים", - "amount": 1200000, - "rate": 3.5, - "term": 240 - }, - "analysis": { - "recommendedRate": 3.1, - "savings": 45000, - "aiReasoning": "שיעור טוב יותר זמין בשוק." - }, - "status": "analyzed", - "createdAt": "2026-04-03T02:16:00.000Z", - "updatedAt": "2026-04-03T02:20:00.000Z" - } - ], - "pagination": { - "page": 1, - "limit": 10, - "total": 1, - "pages": 1 - } + "message": "Enhanced report retrieved from cache", + "data": { ... } } ``` ---- - -#### `GET /offers/stats` +#### Error Responses -Get aggregate offer statistics for the authenticated user. +| Status | Code | Description | +|--------|-------------------|------------------------------------------------| +| 400 | VALIDATION_ERROR | Invalid offerId format | +| 401 | UNAUTHORIZED | Missing or invalid Firebase token | +| 403 | FORBIDDEN | User has not paid (paidAnalyses = false) | +| 403 | FORBIDDEN | Offer belongs to a different user | +| 404 | NOT_FOUND | Offer not found | +| 429 | RATE_LIMIT_EXCEEDED | Too many requests (5/min limit) | +| 500 | INTERNAL_ERROR | Unexpected server error | -**Access:** 🔒 Protected - -**Response: `200 OK`** +#### Error Response Format ```json { - "success": true, - "data": { - "total": 5, - "pending": 1, - "analyzed": 3, - "error": 1, - "savingsTotal": 135000 + "success": false, + "error": { + "code": "FORBIDDEN", + "message": "This feature requires a paid subscription." } } ``` --- -#### `GET /offers/:id` - -Get a single offer by ID. The offer must belong to the authenticated user. - -**Access:** 🔒 Protected +## Data Models -**Path parameters:** +### EnhancedReport -| Param | Type | Description | -|-------|------|-------------| -| `id` | string | Firestore offer document ID | - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { /* OfferShape */ } +```typescript +interface EnhancedReport { + tricks: MortgageTrick[]; + negotiationScript: string; // Hebrew RTL script + insights: StrategicInsight[]; + comparison: Comparison; + generatedAt: string; // ISO 8601 + generatedBy: 'ai' | 'rule-based-fallback'; + processingTimeMs: number; } -``` - -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 404 | Offer not found or does not belong to the user | - ---- -#### `DELETE /offers/:id` - -Delete an offer by ID. Also removes the associated Cloudinary file. -The offer must belong to the authenticated user. - -**Access:** 🔒 Protected - -**Response: `200 OK`** - -```json -{ - "success": true, - "message": "Offer deleted" +interface MortgageTrick { + nameHe: string; + nameEn: string; + descriptionHe: string; + descriptionEn: string; + applicability: 'high' | 'medium' | 'low'; + riskLevel: 'low' | 'medium' | 'high'; + potentialSavings: number | null; // ILS } -``` - -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 404 | Offer not found or does not belong to the user | - ---- - -### Analysis Endpoints -#### `GET /analysis/:offerId` - -Get the full offer document including AI analysis results. -The offer must belong to the authenticated user. - -**Access:** 🔒 Protected - -**Path parameters:** - -| Param | Type | Description | -|-------|------|-------------| -| `offerId` | string | Firestore offer document ID | - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { - "id": "offer-id-xyz789", - "userId": "firestore-uid-abc123", - "originalFile": { - "url": "https://res.cloudinary.com/morty/raw/upload/v1234/morty/offers/file.pdf", - "mimetype": "application/pdf" - }, - "extractedData": { - "bank": "הפועלים", - "amount": 1200000, - "rate": 3.5, - "term": 240 - }, - "analysis": { - "recommendedRate": 3.1, - "savings": 45000, - "aiReasoning": "שיעור טוב יותר זמין בשוק." - }, - "status": "analyzed", - "createdAt": "2026-04-03T02:16:00.000Z", - "updatedAt": "2026-04-03T02:20:00.000Z" - } +interface StrategicInsight { + titleHe: string; + titleEn: string; + bodyHe: string; + bodyEn: string; + icon: 'trending-down' | 'check-circle' | 'target' | 'calendar' | 'shield' | 'info'; } -``` - -**Handling offer status in the UI:** - -| `status` | UI behaviour | -|----------|--------------| -| `pending` | Show spinner + "הניתוח מתבצע..." | -| `analyzed` | Show `PaymentComparisonChart` + `RecommendationCard` | -| `error` | Show error state + retry option | - -**Error responses:** - -| Status | Condition | -|--------|-----------| -| 400 | Missing offer ID | -| 404 | Offer not found or does not belong to the user | - ---- -### Dashboard Endpoints - -#### `GET /dashboard` - -Get an aggregated summary of the user's financial profile and recent offers. -All three Firestore queries run in parallel for minimal latency. - -**Access:** 🔒 Protected - -**Response: `200 OK`** - -```json -{ - "success": true, - "data": { - "financials": { - "id": "firestore-uid-abc123", - "userId": "firestore-uid-abc123", - "income": 15000, - "additionalIncome": 2000, - "expenses": { "housing": 4000, "loans": 1500, "other": 800 }, - "assets": { "savings": 200000, "investments": 50000 }, - "debts": [], - "updatedAt": "2026-04-03T02:16:00.000Z" - }, - "recentOffers": [ - { /* OfferShape */ } - ], - "stats": { - "totalOffers": 5, - "savingsTotal": 135000, - "pending": 1, - "analyzed": 3, - "error": 1 - } - }, - "message": "Dashboard data retrieved successfully" +interface Comparison { + rateDelta: number | null; // bank - portfolio (percentage points) + monthlySaving: number | null; // ILS/month + totalSaving: number | null; // ILS over full term + loanAmount: number; // ILS + termYears: number; + bankRate: number | null; // % + portfolioRate: number | null; // % + trackComparison: TrackComparison[]; } -``` - -> **Note:** `financials` is `null` if the user has not set up their profile yet. -> `recentOffers` contains at most **5** offers, sorted by `createdAt` descending. - ---- - -### Health Endpoint - -#### `GET /health` - -Health check endpoint. Does not require authentication. - -**Access:** Public - -**Response: `200 OK`** -```json -{ - "status": "ok", - "timestamp": "2026-04-03T02:16:00.000Z" +interface TrackComparison { + name: string; + bankRate: number | null; + portfolioRate: number | null; + delta: number | null; } ``` --- -## Migration Notes - -### MongoDB → Firestore - -The backend was migrated from MongoDB/Mongoose to Google Cloud Firestore. -Frontend code must be updated to handle the following changes: - -#### ID Field - -| Before (MongoDB) | After (Firestore) | -|------------------|-------------------| -| `user._id` (ObjectId string) | `user.id` (Firestore string ID) | -| `offer._id` | `offer.id` | - -**Action required:** Replace all `._id` references with `.id` in frontend code. - -```js -// ❌ Old -const userId = user._id; -const key = offer._id; - -// ✅ New -const userId = user.id; -const key = offer.id; -``` - -#### Timestamps - -| Before (MongoDB) | After (Firestore) | -|------------------|-------------------| -| `Date` object (Mongoose) | ISO 8601 string | - -**Action required:** Parse timestamps with `new Date(isoString)` before formatting. - -```js -// ✅ Correct -const formatted = new Date(offer.createdAt).toLocaleDateString('he-IL'); -``` - -#### Response Envelope - -All responses now use `{ success, data, message }`. Previously some endpoints -returned data directly. - -```js -// ✅ Correct -const { data } = await axios.post('/auth/login', credentials); -const { token, refreshToken, user } = data.data; -``` - -#### Null Analysis Fields - -When `offer.status === 'pending'`, the `analysis` sub-object exists but its -fields are `null`. Always use optional chaining: - -```js -// ✅ Correct -const savings = offer.analysis?.savings ?? 0; -const reasoning = offer.analysis?.aiReasoning || 'אין נימוק זמין'; -``` - ---- - -## Frontend Integration Guide - -### Axios Setup - -```js -// src/services/api.js -import axios from 'axios'; -import { getStoredToken, getStoredRefreshToken, setStoredToken, setStoredRefreshToken, clearStorage } from '../utils/storage'; - -const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1', - headers: { 'Content-Type': 'application/json' }, -}); - -// Attach access token to every request -api.interceptors.request.use((config) => { - const token = getStoredToken(); - if (token) config.headers.Authorization = `Bearer ${token}`; - return config; -}); - -// Auto-refresh on 401 -api.interceptors.response.use( - (response) => response, - async (error) => { - const original = error.config; - if (error.response?.status === 401 && !original._retry) { - original._retry = true; - try { - const refreshToken = getStoredRefreshToken(); - const { data } = await axios.post( - `${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/auth/refresh`, - { refreshToken } - ); - setStoredToken(data.data.token); - setStoredRefreshToken(data.data.refreshToken); - original.headers.Authorization = `Bearer ${data.data.token}`; - return api(original); - } catch { - clearStorage(); - window.location.href = '/login'; - } - } - return Promise.reject(error); - } -); - -export default api; -``` - -### Auth Service - -```js -// src/services/authService.js -import api from './api'; -import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; - -export const login = async ({ email, password }) => { - const { data } = await api.post('/auth/login', { email, password }); - // data.data contains { token, refreshToken, user } - // user.id is a Firestore string ID (not _id) - return data.data; -}; - -export const register = async ({ email, password, phone }) => { - const { data } = await api.post('/auth/register', { email, password, phone }); - return data.data; -}; - -/** - * Google sign-in via Firebase popup. - * Returns the same { token, refreshToken, user } shape as login/register. - */ -export const googleLogin = async () => { - const auth = getAuth(); - const provider = new GoogleAuthProvider(); - - // Step 1: Firebase popup — obtains Google credentials - const result = await signInWithPopup(auth, provider); - const idToken = await result.user.getIdToken(); - - // Step 2: Exchange Firebase ID token for custom JWTs - const { data } = await api.post('/auth/google', { idToken }); - return data.data; // { token, refreshToken, user } -}; - -export const refreshToken = async (refreshToken) => { - const { data } = await api.post('/auth/refresh', { refreshToken }); - return data.data; // { token, refreshToken } -}; - -export const logout = async (refreshToken) => { - await api.post('/auth/logout', { refreshToken }); -}; - -export const getMe = async () => { - const { data } = await api.get('/auth/me'); - return data.data.user; -}; -``` - -### Normalise User Shape - -For backward compatibility during migration, use a normaliser: - -```js -// src/utils/normalizers.js -export const normalizeUser = (user) => ({ - id: user.id || user._id, // backward-compat shim - email: user.email, - phone: user.phone || '', - verified: user.verified || false, -}); -``` - -### Date Formatting - -```js -// src/utils/formatters.js -export const formatDate = (iso) => - iso ? new Date(iso).toLocaleDateString('he-IL') : '—'; - -export const formatCurrency = (amount) => - amount != null - ? new Intl.NumberFormat('he-IL', { style: 'currency', currency: 'ILS' }).format(amount) - : '—'; -``` - -### Mock Data for Tests - -```js -// Use in all test files -export const mockUser = { - id: 'firestore-uid-abc123', // string, NOT ObjectId - email: 'test@morty.co.il', - phone: '050-0000000', - verified: true, - createdAt: '2026-04-03T02:16:00.000Z', - updatedAt: '2026-04-03T02:16:00.000Z', -}; - -export const mockFinancials = { - id: 'firestore-uid-abc123', - userId: 'firestore-uid-abc123', - income: 15000, - additionalIncome: 2000, - expenses: { housing: 4000, loans: 1500, other: 800 }, - assets: { savings: 200000, investments: 50000 }, - debts: [], - updatedAt: '2026-04-03T02:16:00.000Z', -}; - -export const mockOffer = { - id: 'offer-id-xyz789', // string, NOT ObjectId - userId: 'firestore-uid-abc123', - originalFile: { - url: 'https://res.cloudinary.com/morty/raw/upload/v1234/morty/offers/file.pdf', - mimetype: 'application/pdf', - }, - extractedData: { - bank: 'הפועלים', - amount: 1200000, - rate: 3.5, - term: 240, - }, - analysis: { - recommendedRate: 3.1, - savings: 45000, - aiReasoning: 'שיעור טוב יותר זמין בשוק.', - }, - status: 'analyzed', - createdAt: '2026-04-03T02:16:00.000Z', - updatedAt: '2026-04-03T02:20:00.000Z', -}; - -export const mockPendingOffer = { - ...mockOffer, - id: 'offer-id-pending', - extractedData: { bank: '', amount: null, rate: null, term: null }, - analysis: { recommendedRate: null, savings: null, aiReasoning: '' }, - status: 'pending', -}; - -export const mockDashboard = { - financials: mockFinancials, - recentOffers: [mockOffer], - stats: { - totalOffers: 1, - savingsTotal: 45000, - pending: 0, - analyzed: 1, - error: 0, - }, -}; -``` - ---- - -## Environment Variables - -### Backend (`.env`) - -| Variable | Required | Description | -|----------|----------|-------------| -| `NODE_ENV` | ✅ | `development` \| `production` | -| `PORT` | ✅ | Server port (default: 5000) | -| `JWT_SECRET` | ✅ | HS256 secret for access tokens (min 32 chars) | -| `JWT_REFRESH_SECRET` | ✅ | HS256 secret for refresh tokens (min 32 chars) | -| `FIREBASE_PROJECT_ID` | ✅ | GCP project ID | -| `FIREBASE_CLIENT_EMAIL` | ✅ | Firebase Admin SDK service account email | -| `FIREBASE_PRIVATE_KEY` | ✅ | Firebase Admin SDK private key (with `\n` newlines) | -| `CLOUDINARY_CLOUD_NAME` | ✅ | Cloudinary cloud name | -| `CLOUDINARY_API_KEY` | ✅ | Cloudinary API key | -| `CLOUDINARY_API_SECRET` | ✅ | Cloudinary API secret | -| `OPENAI_API_KEY` | ✅ | OpenAI API key for GPT-4o Vision | -| `CORS_ORIGIN` | ✅ | Frontend origin (e.g., `https://morty.app`) | -| `GOOGLE_APPLICATION_CREDENTIALS` | ❌ | Path to service account JSON (local dev alternative) | -| `LOG_LEVEL` | ❌ | Winston log level (default: `info`) | - -### Frontend (`.env`) +## Security -| Variable | Required | Description | -|----------|----------|-------------| -| `VITE_API_URL` | ✅ | Backend API base URL (e.g., `https://morty-backend-h9sb.onrender.com/api/v1`) | -| `VITE_FIREBASE_API_KEY` | ✅ | Firebase web app API key | -| `VITE_FIREBASE_AUTH_DOMAIN` | ✅ | Firebase Auth domain (e.g., `myproject.firebaseapp.com`) | -| `VITE_FIREBASE_PROJECT_ID` | ✅ | Firebase GCP project ID | -| `VITE_FIREBASE_STORAGE_BUCKET` | ❌ | Firebase Storage bucket | -| `VITE_FIREBASE_MESSAGING_SENDER_ID` | ❌ | Firebase Cloud Messaging sender ID | -| `VITE_FIREBASE_APP_ID` | ✅ | Firebase web app ID | +- All endpoints require Firebase ID token authentication +- Ownership validation: users can only access their own offers +- Paid access enforced server-side (not just client-side) +- Rate limiting: 5 requests/minute for enhanced endpoint +- Input validation on all parameters +- AI outputs sanitised before storage and response diff --git a/package.json b/package.json index 1031af0..215107d 100644 --- a/package.json +++ b/package.json @@ -1,50 +1,61 @@ { "name": "morty-backend", "version": "1.0.0", - "description": "Morty – AI-powered mortgage analysis backend", + "description": "Morty AI-powered mortgage analysis backend", "main": "src/index.js", "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "test": "jest --forceExit --detectOpenHandles", - "test:coverage": "jest --coverage --forceExit --detectOpenHandles", - "fetch-rates": "node scripts/fetchRates.js" - }, - "engines": { - "node": ">=18.0.0" + "test": "jest --runInBand --forceExit", + "test:coverage": "jest --coverage --runInBand --forceExit", + "lint": "eslint src/ --ext .js" }, "dependencies": { - "axios": "^1.7.2", - "bcryptjs": "^2.4.3", - "cloudinary": "^2.9.0", - "cors": "^2.8.6", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "express-rate-limit": "^7.3.1", - "firebase-admin": "^10.3.0", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "firebase-admin": "^11.11.1", + "openai": "^4.20.1", + "stripe": "^14.5.0", + "cors": "^2.8.5", "helmet": "^7.1.0", - "joi": "^17.13.1", - "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", - "multer": "^1.4.5-lts.1", + "dotenv": "^16.3.1", + "joi": "^17.11.0", + "winston": "^3.11.0", "node-cron": "^3.0.3", - "nodemailer": "^8.0.5", - "openai": "^4.52.0", - "stripe": "^17.4.0", - "winston": "^3.13.0" + "axios": "^1.6.2", + "multer": "^1.4.5-lts.1", + "cloudinary": "^1.41.3", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "uuid": "^9.0.1" }, "devDependencies": { "jest": "^29.7.0", - "node-mocks-http": "^1.14.1", - "nodemon": "^3.1.4", - "supertest": "^7.0.0" + "supertest": "^6.3.3", + "nodemon": "^3.0.2", + "eslint": "^8.54.0" }, "jest": { "testEnvironment": "node", "testMatch": [ "**/__tests__/**/*.test.js", - "**/src/tests/**/*.test.js", - "**/src/__tests__/**/*.test.js" - ] + "**/src/__tests__/**/*.test.js", + "**/src/tests/**/*.test.js" + ], + "collectCoverageFrom": [ + "src/**/*.js", + "!src/index.js", + "!src/config/**", + "!src/cron/**" + ], + "coverageThreshold": { + "global": { + "branches": 70, + "functions": 70, + "lines": 70, + "statements": 70 + } + } } } diff --git a/src/config/collections.js b/src/config/collections.js index 88b4041..d8045be 100644 --- a/src/config/collections.js +++ b/src/config/collections.js @@ -1,671 +1,17 @@ -/** - * Firestore Collections Design - * - * This module is the single source of truth for: - * - Collection names (avoid magic strings throughout the codebase) - * - Document factory functions (create well-shaped documents) - * - Field-level validators (lightweight, synchronous checks) - * - Index definitions (documented here; applied via Firebase Console or - * firestore.indexes.json) - * - * Collections - * ─────────── - * users – one document per registered user (doc ID == user UID) - * financials – one document per user (doc ID == userId) - * offers – many documents per user (auto-generated doc IDs) - * mortgage_rates – BOI average mortgage rates (doc ID == date or 'latest') - * community_profiles – anonymized user profiles for community intelligence - * payments – Stripe payment records (doc ID == Stripe session ID) - * - * Indexes - * ─────── - * users: email (single-field, ascending) – for login lookup - * financials: userId (single-field, ascending) – for profile fetch - * offers: (userId ASC, createdAt DESC) – composite, for list queries - * mortgage_rates: (date DESC) – for historical queries - * community_profiles: (incomeBin ASC) – for range queries - * (incomeBin ASC, loanBin ASC) – compound for matching - * profileHash (single-field) – for exact lookups - * payments: (userId ASC, createdAt DESC) – for payment history - */ - 'use strict'; -// ─── Collection Names ──────────────────────────────────────────────────────── - -/** @type {Readonly<{USERS: string, FINANCIALS: string, OFFERS: string, MORTGAGE_RATES: string, COMMUNITY_PROFILES: string, PAYMENTS: string}>} */ -const COLLECTIONS = Object.freeze({ - USERS: 'users', - FINANCIALS: 'financials', - OFFERS: 'offers', - MORTGAGE_RATES: 'mortgage_rates', - COMMUNITY_PROFILES: 'community_profiles', - PAYMENTS: 'payments', -}); - -// ─── Offer Status Enum ─────────────────────────────────────────────────────── - -/** @type {Readonly<{PENDING: string, ANALYZED: string, ERROR: string}>} */ -const OFFER_STATUS = Object.freeze({ - PENDING: 'pending', - ANALYZED: 'analyzed', - ERROR: 'error', -}); - -// ─── Rates Source Enum ─────────────────────────────────────────────────────── - -/** @type {Readonly<{BOI: string, FALLBACK: string}>} */ -const RATES_SOURCE = Object.freeze({ - BOI: 'bank_of_israel', - FALLBACK: 'fallback', -}); - -// ─── Payment Status Enum ───────────────────────────────────────────────────── - -/** @type {Readonly<{PENDING: string, COMPLETED: string, EXPIRED: string}>} */ -const PAYMENT_STATUS = Object.freeze({ - PENDING: 'pending', - COMPLETED: 'completed', - EXPIRED: 'expired', -}); - -// ─── Document Factories ────────────────────────────────────────────────────── - -/** - * Build a new `users` document. - * - * @param {object} params - * @param {string} params.id - Firestore document ID (user UID) - * @param {string} params.email - Unique, lowercase email address - * @param {string} params.password - bcrypt-hashed password - * @param {string} [params.phone] - Phone number (default '') - * @param {boolean} [params.verified] - Email verified flag (default false) - * @returns {object} Firestore-ready user document - */ -function createUserDocument({ id, email, password, phone = '', verified = false }) { - if (!id) throw new Error('createUserDocument: id is required'); - if (!email) throw new Error('createUserDocument: email is required'); - if (!password) throw new Error('createUserDocument: password is required'); - - const now = new Date().toISOString(); - return { - id, - email: email.toLowerCase().trim(), - password, - phone: phone || '', - verified: Boolean(verified), - refreshToken: null, - paidAnalyses: false, - createdAt: now, - updatedAt: now, - }; -} - -/** - * Build a new `financials` document. - * - * The document ID is always the userId so there is at most one financial - * profile per user (upsert semantics). - * - * @param {object} params - * @param {string} params.userId - Owner's user ID (also the doc ID) - * @param {number} params.income - Primary monthly income (>= 0) - * @param {number} [params.additionalIncome] - Secondary income (default 0) - * @param {object} [params.expenses] - Expense breakdown - * @param {number} [params.expenses.housing] - Housing costs (default 0) - * @param {number} [params.expenses.loans] - Loan repayments (default 0) - * @param {number} [params.expenses.other] - Other expenses (default 0) - * @param {object} [params.assets] - Asset breakdown - * @param {number} [params.assets.savings] - Savings (default 0) - * @param {number} [params.assets.investments]- Investments (default 0) - * @param {Array} [params.debts] - Debt list [{type, amount}] - * @returns {object} Firestore-ready financials document - */ -function createFinancialDocument({ - userId, - income, - additionalIncome = 0, - expenses = {}, - assets = {}, - debts = [], -}) { - if (!userId) throw new Error('createFinancialDocument: userId is required'); - if (income === undefined || income === null) { - throw new Error('createFinancialDocument: income is required'); - } - - return { - id: userId, - userId, - income: Number(income), - additionalIncome: Number(additionalIncome) || 0, - expenses: { - housing: Number(expenses.housing) || 0, - loans: Number(expenses.loans) || 0, - other: Number(expenses.other) || 0, - }, - assets: { - savings: Number(assets.savings) || 0, - investments: Number(assets.investments) || 0, - }, - debts: Array.isArray(debts) ? debts : [], - updatedAt: new Date().toISOString(), - }; -} - -/** - * Build a new `offers` document. - * - * @param {object} params - * @param {string} params.id - Firestore document ID (auto-generated) - * @param {string} params.userId - Owner's user ID - * @param {object} params.originalFile - Uploaded file metadata - * @param {string} params.originalFile.url - Cloudinary URL - * @param {string} params.originalFile.mimetype - MIME type - * @param {object} [params.extractedData] - AI-extracted mortgage data - * @param {string} [params.extractedData.bank] - Bank name - * @param {number|null} [params.extractedData.amount] - Loan amount - * @param {number|null} [params.extractedData.rate] - Interest rate - * @param {number|null} [params.extractedData.term] - Loan term (months) - * @param {object} [params.analysis] - AI analysis results - * @param {number|null} [params.analysis.recommendedRate] - Recommended rate - * @param {number|null} [params.analysis.savings] - Potential savings - * @param {string} [params.analysis.aiReasoning] - AI reasoning text - * @param {string} [params.status] - Offer status (default 'pending') - * @returns {object} Firestore-ready offer document - */ -function createOfferDocument({ - id, - userId, - originalFile, - extractedData = {}, - analysis = {}, - status = OFFER_STATUS.PENDING, -}) { - if (!id) throw new Error('createOfferDocument: id is required'); - if (!userId) throw new Error('createOfferDocument: userId is required'); - if (!originalFile || !originalFile.url) { - throw new Error('createOfferDocument: originalFile.url is required'); - } - if (!originalFile.mimetype) { - throw new Error('createOfferDocument: originalFile.mimetype is required'); - } - if (!OFFER_STATUS_VALUES.includes(status)) { - throw new Error(`createOfferDocument: invalid status '${status}'`); - } - - const now = new Date().toISOString(); - return { - id, - userId, - originalFile: { - url: originalFile.url, - mimetype: originalFile.mimetype, - }, - extractedData: { - bank: extractedData.bank || '', - amount: extractedData.amount !== undefined ? extractedData.amount : null, - rate: extractedData.rate !== undefined ? extractedData.rate : null, - term: extractedData.term !== undefined ? extractedData.term : null, - }, - analysis: { - recommendedRate: analysis.recommendedRate !== undefined ? analysis.recommendedRate : null, - savings: analysis.savings !== undefined ? analysis.savings : null, - aiReasoning: analysis.aiReasoning || '', - }, - status, - createdAt: now, - updatedAt: now, - }; -} - -/** - * Build a new `mortgage_rates` document. - * - * @param {object} params - * @param {string} params.date - ISO date string of the fetch - * @param {object} params.fetchPeriod - Period covered - * @param {string} params.fetchPeriod.start - Start period (YYYY-MM) - * @param {string} params.fetchPeriod.end - End period (YYYY-MM) - * @param {object} params.tracks - Track data by type - * @param {object} params.averages - Flat averages { fixed, cpi, prime, variable } - * @param {string} params.source - Data source ('bank_of_israel' | 'fallback') - * @param {string} [params.sourceUrl] - URL of the data source - * @returns {object} Firestore-ready mortgage_rates document - */ -function createMortgageRatesDocument({ - date, - fetchPeriod, - tracks, - averages, - source, - sourceUrl = 'https://www.boi.org.il/en/economic-roles/statistics/', -}) { - if (!date) throw new Error('createMortgageRatesDocument: date is required'); - if (!tracks || typeof tracks !== 'object') { - throw new Error('createMortgageRatesDocument: tracks object is required'); - } - if (!averages || typeof averages !== 'object') { - throw new Error('createMortgageRatesDocument: averages object is required'); - } - if (!source) throw new Error('createMortgageRatesDocument: source is required'); - - return { - date, - fetchPeriod: fetchPeriod || null, - tracks, - averages: { - fixed: averages.fixed ?? null, - cpi: averages.cpi ?? null, - prime: averages.prime ?? null, - variable: averages.variable ?? null, - }, - source, - sourceUrl, - updatedAt: new Date().toISOString(), - }; -} - /** - * Build a new `community_profiles` document. - * - * Stores an anonymized user profile for community intelligence matching. - * No PII is stored – only binned financial data and bank/branch/rates. - * - * @param {object} params - * @param {string} params.profileHash - SHA-256 hash of binned profile - * @param {number} params.incomeBin - Binned monthly income - * @param {number} params.loanBin - Binned loan amount - * @param {number} params.ltvBin - Binned LTV percentage - * @param {number} params.stabilityBin - Binned stability preference - * @param {string} [params.bank] - Bank name (Hebrew) - * @param {string} [params.branch] - Branch name (Hebrew) - * @param {object} [params.rates] - Actual rates received - * @param {number} [params.rates.fixed] - Fixed rate - * @param {number} [params.rates.cpi] - CPI-indexed rate - * @param {number} [params.rates.prime] - Prime rate - * @param {number} [params.rates.variable] - Variable rate - * @param {number} [params.weightedRate] - Weighted average rate - * @returns {object} Firestore-ready community_profiles document + * Firestore collection names. + * Centralised to avoid magic strings throughout the codebase. */ -function createCommunityProfileDocument({ - profileHash, - incomeBin, - loanBin, - ltvBin, - stabilityBin, - bank = null, - branch = null, - rates = null, - weightedRate = null, -}) { - if (!profileHash) throw new Error('createCommunityProfileDocument: profileHash is required'); - if (incomeBin === undefined || incomeBin === null) { - throw new Error('createCommunityProfileDocument: incomeBin is required'); - } - if (loanBin === undefined || loanBin === null) { - throw new Error('createCommunityProfileDocument: loanBin is required'); - } - if (ltvBin === undefined || ltvBin === null) { - throw new Error('createCommunityProfileDocument: ltvBin is required'); - } - if (stabilityBin === undefined || stabilityBin === null) { - throw new Error('createCommunityProfileDocument: stabilityBin is required'); - } - - const now = new Date().toISOString(); - return { - profileHash, - incomeBin: Number(incomeBin), - loanBin: Number(loanBin), - ltvBin: Number(ltvBin), - stabilityBin: Number(stabilityBin), - bank: bank || null, - branch: branch || null, - rates: rates || null, - weightedRate: weightedRate != null ? Number(weightedRate) : null, - consent: true, - createdAt: now, - updatedAt: now, - }; -} - -/** - * Build a new `payments` document. - * - * Stores a Stripe payment record for audit trail. - * - * @param {object} params - * @param {string} params.sessionId - Stripe Checkout Session ID (also doc ID) - * @param {string} params.userId - User's Firestore ID - * @param {string} [params.portfolioId] - Optional linked portfolio ID - * @param {string} [params.product] - Product identifier (default 'expert_analysis') - * @param {string} [params.status] - Payment status (default 'pending') - * @returns {object} Firestore-ready payments document - */ -function createPaymentDocument({ - sessionId, - userId, - portfolioId = null, - product = 'expert_analysis', - status = PAYMENT_STATUS.PENDING, -}) { - if (!sessionId) throw new Error('createPaymentDocument: sessionId is required'); - if (!userId) throw new Error('createPaymentDocument: userId is required'); - if (!PAYMENT_STATUS_VALUES.includes(status)) { - throw new Error(`createPaymentDocument: invalid status '${status}'`); - } - - const now = new Date().toISOString(); - return { - sessionId, - userId, - portfolioId: portfolioId || null, - product, - status, - createdAt: now, - updatedAt: now, - }; -} - -// ─── Field Validators ──────────────────────────────────────────────────────── - -/** - * Validate a user document's required fields. - * - * @param {object} doc - Partial or full user document - * @returns {{ valid: boolean, errors: string[] }} - */ -function validateUserDocument(doc) { - const errors = []; - if (!doc.id || typeof doc.id !== 'string') errors.push('id must be a non-empty string'); - if (!doc.email || typeof doc.email !== 'string') errors.push('email must be a non-empty string'); - if (!doc.password || typeof doc.password !== 'string') errors.push('password must be a non-empty string'); - if (typeof doc.verified !== 'boolean') errors.push('verified must be a boolean'); - return { valid: errors.length === 0, errors }; -} - -/** - * Validate a financials document's required fields. - * - * @param {object} doc - Partial or full financials document - * @returns {{ valid: boolean, errors: string[] }} - */ -function validateFinancialDocument(doc) { - const errors = []; - if (!doc.userId || typeof doc.userId !== 'string') errors.push('userId must be a non-empty string'); - if (typeof doc.income !== 'number' || doc.income < 0) errors.push('income must be a non-negative number'); - if (doc.expenses) { - ['housing', 'loans', 'other'].forEach((key) => { - if (doc.expenses[key] !== undefined && typeof doc.expenses[key] !== 'number') { - errors.push(`expenses.${key} must be a number`); - } - }); - } - if (doc.assets) { - ['savings', 'investments'].forEach((key) => { - if (doc.assets[key] !== undefined && typeof doc.assets[key] !== 'number') { - errors.push(`assets.${key} must be a number`); - } - }); - } - if (doc.debts !== undefined && !Array.isArray(doc.debts)) { - errors.push('debts must be an array'); - } - return { valid: errors.length === 0, errors }; -} - -/** - * Validate an offer document's required fields. - * - * @param {object} doc - Partial or full offer document - * @returns {{ valid: boolean, errors: string[] }} - */ -function validateOfferDocument(doc) { - const errors = []; - if (!doc.id || typeof doc.id !== 'string') errors.push('id must be a non-empty string'); - if (!doc.userId || typeof doc.userId !== 'string') errors.push('userId must be a non-empty string'); - if (!doc.originalFile || !doc.originalFile.url) errors.push('originalFile.url is required'); - if (!doc.originalFile || !doc.originalFile.mimetype) errors.push('originalFile.mimetype is required'); - if (!OFFER_STATUS_VALUES.includes(doc.status)) { - errors.push(`status must be one of: ${OFFER_STATUS_VALUES.join(', ')}`); - } - return { valid: errors.length === 0, errors }; -} - -/** - * Validate a mortgage_rates document's required fields. - * - * @param {object} doc - Partial or full mortgage_rates document - * @returns {{ valid: boolean, errors: string[] }} - */ -function validateMortgageRatesDocument(doc) { - const errors = []; - if (!doc.date || typeof doc.date !== 'string') errors.push('date must be a non-empty string'); - if (!doc.tracks || typeof doc.tracks !== 'object') errors.push('tracks must be an object'); - if (!doc.averages || typeof doc.averages !== 'object') errors.push('averages must be an object'); - if (!doc.source || typeof doc.source !== 'string') errors.push('source must be a non-empty string'); - - // Validate track types if tracks exist - if (doc.tracks && typeof doc.tracks === 'object') { - const validTracks = ['fixed', 'cpi', 'prime', 'variable']; - for (const key of Object.keys(doc.tracks)) { - if (!validTracks.includes(key)) { - errors.push(`tracks contains unknown track type: ${key}`); - } - } - } - - return { valid: errors.length === 0, errors }; -} - -/** - * Validate a community_profiles document's required fields. - * - * @param {object} doc - Partial or full community_profiles document - * @returns {{ valid: boolean, errors: string[] }} - */ -function validateCommunityProfileDocument(doc) { - const errors = []; - if (!doc.profileHash || typeof doc.profileHash !== 'string') { - errors.push('profileHash must be a non-empty string'); - } - if (typeof doc.incomeBin !== 'number' || doc.incomeBin < 0) { - errors.push('incomeBin must be a non-negative number'); - } - if (typeof doc.loanBin !== 'number' || doc.loanBin < 0) { - errors.push('loanBin must be a non-negative number'); - } - if (typeof doc.ltvBin !== 'number' || doc.ltvBin < 0) { - errors.push('ltvBin must be a non-negative number'); - } - if (typeof doc.stabilityBin !== 'number') { - errors.push('stabilityBin must be a number'); - } - if (doc.consent !== true) { - errors.push('consent must be true'); - } - if (doc.rates !== null && typeof doc.rates !== 'object') { - errors.push('rates must be an object or null'); - } - if (doc.weightedRate !== null && typeof doc.weightedRate !== 'number') { - errors.push('weightedRate must be a number or null'); - } - return { valid: errors.length === 0, errors }; -} - -/** - * Validate a payments document's required fields. - * - * @param {object} doc - Partial or full payments document - * @returns {{ valid: boolean, errors: string[] }} - */ -function validatePaymentDocument(doc) { - const errors = []; - if (!doc.sessionId || typeof doc.sessionId !== 'string') { - errors.push('sessionId must be a non-empty string'); - } - if (!doc.userId || typeof doc.userId !== 'string') { - errors.push('userId must be a non-empty string'); - } - if (!PAYMENT_STATUS_VALUES.includes(doc.status)) { - errors.push(`status must be one of: ${PAYMENT_STATUS_VALUES.join(', ')}`); - } - return { valid: errors.length === 0, errors }; -} - -// ─── Index Definitions (documentation) ────────────────────────────────────── - -/** - * Firestore index definitions. - * - * These are applied via the Firebase Console or firestore.indexes.json. - * They are documented here for reference and used by initCollections.js. - * - * @type {Array} - */ -const INDEX_DEFINITIONS = Object.freeze([ - { - collection: COLLECTIONS.USERS, - description: 'Single-field index on email for login lookup', - fields: [{ fieldPath: 'email', order: 'ASCENDING' }], - type: 'single', - }, - { - collection: COLLECTIONS.FINANCIALS, - description: 'Single-field index on userId for profile fetch', - fields: [{ fieldPath: 'userId', order: 'ASCENDING' }], - type: 'single', - }, - { - collection: COLLECTIONS.OFFERS, - description: 'Composite index on userId (ASC) + createdAt (DESC) for offer list queries', - fields: [ - { fieldPath: 'userId', order: 'ASCENDING' }, - { fieldPath: 'createdAt', order: 'DESCENDING' }, - ], - type: 'composite', - }, - { - collection: COLLECTIONS.MORTGAGE_RATES, - description: 'Single-field index on date (DESC) for latest rates query', - fields: [{ fieldPath: 'date', order: 'DESCENDING' }], - type: 'single', - }, - { - collection: COLLECTIONS.COMMUNITY_PROFILES, - description: 'Single-field index on profileHash for exact lookups', - fields: [{ fieldPath: 'profileHash', order: 'ASCENDING' }], - type: 'single', - }, - { - collection: COLLECTIONS.COMMUNITY_PROFILES, - description: 'Composite index on incomeBin (ASC) for range queries with ordering', - fields: [{ fieldPath: 'incomeBin', order: 'ASCENDING' }], - type: 'single', - }, - { - collection: COLLECTIONS.COMMUNITY_PROFILES, - description: 'Composite index on incomeBin (ASC) + loanBin (ASC) for compound matching', - fields: [ - { fieldPath: 'incomeBin', order: 'ASCENDING' }, - { fieldPath: 'loanBin', order: 'ASCENDING' }, - ], - type: 'composite', - }, - { - collection: COLLECTIONS.PAYMENTS, - description: 'Composite index on userId (ASC) + createdAt (DESC) for payment history', - fields: [ - { fieldPath: 'userId', order: 'ASCENDING' }, - { fieldPath: 'createdAt', order: 'DESCENDING' }, - ], - type: 'composite', - }, -]); - -// ─── Firestore Index Configuration (firestore.indexes.json format) ─────────── - -/** - * Composite index configuration in Firestore CLI format. - * Can be written to firestore.indexes.json for deployment. - */ -const FIRESTORE_INDEXES = Object.freeze({ - indexes: [ - { - collectionGroup: COLLECTIONS.OFFERS, - queryScope: 'COLLECTION', - fields: [ - { fieldPath: 'userId', order: 'ASCENDING' }, - { fieldPath: 'createdAt', order: 'DESCENDING' }, - ], - }, - { - collectionGroup: COLLECTIONS.COMMUNITY_PROFILES, - queryScope: 'COLLECTION', - fields: [ - { fieldPath: 'incomeBin', order: 'ASCENDING' }, - { fieldPath: 'loanBin', order: 'ASCENDING' }, - ], - }, - { - collectionGroup: COLLECTIONS.PAYMENTS, - queryScope: 'COLLECTION', - fields: [ - { fieldPath: 'userId', order: 'ASCENDING' }, - { fieldPath: 'createdAt', order: 'DESCENDING' }, - ], - }, - ], - fieldOverrides: [], -}); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Array of valid offer status strings (for quick includes() checks). */ -const OFFER_STATUS_VALUES = Object.values(OFFER_STATUS); - -/** Array of valid rates source strings. */ -const RATES_SOURCE_VALUES = Object.values(RATES_SOURCE); - -/** Array of valid payment status strings. */ -const PAYMENT_STATUS_VALUES = Object.values(PAYMENT_STATUS); - -// ─── Exports ───────────────────────────────────────────────────────────────── - -module.exports = { - // Collection name constants - COLLECTIONS, - - // Offer status enum - OFFER_STATUS, - OFFER_STATUS_VALUES, - - // Rates source enum - RATES_SOURCE, - RATES_SOURCE_VALUES, - - // Payment status enum - PAYMENT_STATUS, - PAYMENT_STATUS_VALUES, - - // Document factories - createUserDocument, - createFinancialDocument, - createOfferDocument, - createMortgageRatesDocument, - createCommunityProfileDocument, - createPaymentDocument, - - // Field validators - validateUserDocument, - validateFinancialDocument, - validateOfferDocument, - validateMortgageRatesDocument, - validateCommunityProfileDocument, - validatePaymentDocument, - - // Index definitions (documentation + CLI config) - INDEX_DEFINITIONS, - FIRESTORE_INDEXES, +const COLLECTIONS = { + USERS: 'users', + OFFERS: 'offers', + PORTFOLIOS: 'portfolios', + RATES: 'rates', + WIZARD_INPUTS: 'wizardInputs', + FINANCIAL_PROFILES: 'financialProfiles', + COMMUNITY_PORTFOLIOS: 'communityPortfolios', }; + +module.exports = COLLECTIONS; diff --git a/src/config/db.js b/src/config/db.js index 0145035..a05912b 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -1,14 +1,20 @@ +'use strict'; + +const { getFirestore } = require('./firebase'); +const logger = require('../utils/logger'); + +let db; + /** - * Database configuration – Firestore migration shim. - * - * This file previously contained the Mongoose/MongoDB connection. - * It now re-exports the Firestore `db` instance from `firestore.js` - * so that any code still importing `db.js` continues to work without - * modification during the incremental migration. - * - * New code should import directly from `./firestore`. + * Get the Firestore database instance (singleton). + * @returns {FirebaseFirestore.Firestore} */ +function getDb() { + if (!db) { + db = getFirestore(); + logger.info('Firestore connection established'); + } + return db; +} -const db = require('./firestore'); - -module.exports = db; +module.exports = { getDb }; diff --git a/src/config/firebase.js b/src/config/firebase.js index bd0d8c1..ebcc0e3 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -1,96 +1,74 @@ -/** - * Firebase Admin SDK configuration - * - * Initialises the firebase-admin app once and exports the Firestore client. - * Supports two credential strategies: - * - * Option A – GOOGLE_APPLICATION_CREDENTIALS env var pointing to a service - * account JSON file (recommended for local development). - * - * Option B – Individual credential fields injected via environment variables - * (FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY). - * Recommended for CI / Render deployments where mounting a file is - * not practical. - * - * The module is safe to require multiple times; firebase-admin's - * getApps() guard prevents duplicate initialisation. - */ - 'use strict'; const admin = require('firebase-admin'); const logger = require('../utils/logger'); +let firebaseApp; + /** - * Build a firebase-admin credential from environment variables. - * - * @returns {admin.credential.Credential} + * Initialize Firebase Admin SDK. + * Uses GOOGLE_APPLICATION_CREDENTIALS env var or explicit credentials. */ -function buildCredential() { - // Option A: file-based service account (GOOGLE_APPLICATION_CREDENTIALS) -/* if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { - logger.info('Firebase Admin: using GOOGLE_APPLICATION_CREDENTIALS file'); - return admin.credential.applicationDefault(); - }*/ +function initializeFirebase() { + if (firebaseApp) { + return firebaseApp; + } - // Option B: individual env vars (CI / Render) - const { FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY } = process.env; + try { + const projectId = process.env.FIREBASE_PROJECT_ID; + const clientEmail = process.env.FIREBASE_CLIENT_EMAIL; + const privateKey = process.env.FIREBASE_PRIVATE_KEY + ? process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n') + : undefined; - if (!FIREBASE_PROJECT_ID || !FIREBASE_CLIENT_EMAIL || !FIREBASE_PRIVATE_KEY) { - throw new Error( - 'Firebase Admin SDK: missing credentials. ' + - 'Set GOOGLE_APPLICATION_CREDENTIALS or ' + - 'FIREBASE_PROJECT_ID + FIREBASE_CLIENT_EMAIL + FIREBASE_PRIVATE_KEY.' - ); - } + let credential; + if (projectId && clientEmail && privateKey) { + credential = admin.credential.cert({ + projectId, + clientEmail, + privateKey, + }); + } else { + // Fall back to application default credentials + credential = admin.credential.applicationDefault(); + } - // Render stores the private key with literal \n; replace them with real newlines. - const privateKey = FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'); + firebaseApp = admin.initializeApp({ + credential, + projectId: projectId || process.env.GOOGLE_CLOUD_PROJECT, + }); - logger.info('Firebase Admin: using individual credential env vars'); - return admin.credential.cert({ - projectId: FIREBASE_PROJECT_ID, - clientEmail: FIREBASE_CLIENT_EMAIL, - privateKey, - }); + logger.info('Firebase Admin SDK initialized successfully'); + return firebaseApp; + } catch (error) { + logger.error('Failed to initialize Firebase Admin SDK', { error: error.message }); + throw error; + } } /** - * Initialize (or reuse) the default firebase-admin app. - * - * @returns {admin.app.App} + * Get Firestore instance. */ -function initFirebaseAdmin() { - if (admin.apps.length > 0) { - return admin.apps[0]; +function getFirestore() { + if (!firebaseApp) { + initializeFirebase(); } + return admin.firestore(); +} - const credential = buildCredential(); - const projectId = process.env.FIREBASE_PROJECT_ID; - - const appConfig = { credential }; - if (projectId) { - appConfig.projectId = projectId; +/** + * Get Firebase Auth instance. + */ +function getAuth() { + if (!firebaseApp) { + initializeFirebase(); } - - const app = admin.initializeApp(appConfig); - logger.info('Firebase Admin SDK initialised successfully'); - return app; + return admin.auth(); } -// Initialise on first require -const firebaseApp = initFirebaseAdmin(); - -// Export the Firestore client for use across the application -const db = admin.firestore(); - -// Configure Firestore settings for better performance -db.settings({ - ignoreUndefinedProperties: true, -}); - module.exports = { + initializeFirebase, + getFirestore, + getAuth, admin, - firebaseApp, - db, }; diff --git a/src/controllers/analysisController.js b/src/controllers/analysisController.js index cbf7919..e90bb13 100644 --- a/src/controllers/analysisController.js +++ b/src/controllers/analysisController.js @@ -1,152 +1,68 @@ -/** - * Analysis Controller - * - * Handles analysis-related endpoints: - * GET /api/v1/analysis/:id – get full offer analysis - * POST /api/v1/analysis/enhanced/:offerId – generate enhanced report (paid) - * - * The enhanced analysis compares a user's real bank offer (OCR-extracted) - * against their selected optimized portfolio model, generating: - * - Track-by-track comparison - * - Mortgage tricks and strategies - * - Personalized Hebrew negotiation script - * - Strategic insights - */ - 'use strict'; const offerService = require('../services/offerService'); +const portfolioService = require('../services/portfolioService'); const reportService = require('../services/reportService'); +const { sendSuccess } = require('../utils/response'); const logger = require('../utils/logger'); /** - * GET /api/v1/analysis/:id + * POST /api/v1/analysis/:offerId/enhanced * - * Fetches the full offer document (including analysis sub-object) from - * Firestore and returns it. Ownership is enforced via findByIdAndUserId. + * Generate (or retrieve cached) enhanced AI analysis report for a paid user. * - * @param {import('express').Request} req - * @param {import('express').Response} res - */ -exports.getAnalysis = async (req, res) => { - try { - const userId = req.user.id; - const offerId = req.params.id; - - if (!offerId) { - return res.status(400).json({ success: false, message: 'Offer ID is required' }); - } - - // Fetch offer and enforce ownership in a single call - const offer = await offerService.findByIdAndUserId(offerId, userId); - if (!offer) { - return res.status(404).json({ success: false, message: 'Offer not found' }); - } - - // Return the full OfferShape so the frontend can render all fields - return res.status(200).json({ - success: true, - data: offer, - }); - } catch (err) { - logger.error(`analysisController.getAnalysis error: ${err.message}`); - return res.status(500).json({ success: false, message: 'Server error' }); - } -}; - -/** - * POST /api/v1/analysis/enhanced/:offerId - * - * Generates an enhanced analysis report comparing the user's real bank - * offer (OCR-extracted) to their selected optimized portfolio model. + * Middleware chain: protect → paidAccess → paidEndpointLimiter → validateOfferId * - * Requires: - * - Authentication (protect middleware) - * - Paid access (requirePaidAccess middleware) - * - The offer must be in 'analyzed' status (OCR completed) + * Flow: + * 1. Validate ownership: offerService.findByIdAndUserId(offerId, userId) + * 2. If enhanced report already exists, return it immediately (idempotent). + * 3. Fetch user portfolio: portfolioService.getUserPortfolio(userId) + * 4. Generate report: reportService.generateEnhancedReport(offerId, userId, offer, portfolio) + * 5. Respond with { success: true, data: enhancedReport } * - * Request body: - * { - * portfolioId: string, // Portfolio scenario type - * portfolio: { // Full portfolio object - * id: string, - * name: string, - * nameHe: string, - * termYears: number, - * tracks: Array<{ type, percentage, rate, rateDisplay, amount }>, - * monthlyRepayment: number, - * totalCost: number, - * totalInterest: number - * } - * } - * - * Response: - * { - * success: true, - * data: { - * offerId: string, - * portfolioId: string, - * portfolioName: string, - * portfolioNameHe: string, - * generatedAt: ISO string, - * processingTimeMs: number, - * comparison: { ... }, - * tricks: Array<{ nameHe, nameEn, descriptionHe, descriptionEn, potentialSavings, riskLevel, applicability }>, - * negotiationScript: string (Hebrew), - * insights: Array<{ titleHe, titleEn, bodyHe, bodyEn, icon }>, - * summary: string, - * summaryHe: string - * } - * } - * - * @param {import('express').Request} req + * @param {import('express').Request} req * @param {import('express').Response} res + * @param {import('express').NextFunction} next */ -exports.getEnhancedAnalysis = async (req, res) => { +async function generateEnhancedReport(req, res, next) { try { - const userId = req.user.id; - const offerId = req.params.offerId; + const { offerId } = req.params; + const userId = req.user.uid; - if (!offerId) { - return res.status(400).json({ - success: false, - message: 'Offer ID is required', - }); - } + logger.info('Enhanced report requested', { offerId, userId }); - const { portfolio } = req.body; + // 1. Verify offer exists and belongs to the user + const offer = await offerService.findByIdAndUserId(offerId, userId); - if (!portfolio) { - return res.status(400).json({ - success: false, - message: 'Portfolio data is required in the request body', - }); + // 2. Return cached report if it already exists (idempotent) + if (offer.analysis && offer.analysis.enhanced) { + logger.info('Returning cached enhanced report', { offerId, userId }); + return sendSuccess(res, offer.analysis.enhanced, 200, 'Enhanced report retrieved from cache'); } - // Generate the enhanced report - const report = await reportService.generateEnhancedReport( + // 3. Fetch user's latest portfolio + const portfolio = await portfolioService.getUserPortfolio(userId); + + // 4. Generate the enhanced report (AI + fallback) + const enhancedReport = await reportService.generateEnhancedReport( offerId, userId, + offer, portfolio ); - return res.status(200).json({ - success: true, - data: report, + // 5. Respond with the report + logger.info('Enhanced report generated and returned', { + offerId, + userId, + generatedBy: enhancedReport.generatedBy, + processingTimeMs: enhancedReport.processingTimeMs, }); - } catch (err) { - // Handle known error types with appropriate status codes - if (err.statusCode) { - return res.status(err.statusCode).json({ - success: false, - message: err.message, - }); - } - logger.error(`analysisController.getEnhancedAnalysis error: ${err.message}`); - return res.status(500).json({ - success: false, - message: 'Failed to generate enhanced analysis report', - }); + return sendSuccess(res, enhancedReport, 201, 'Enhanced report generated successfully'); + } catch (error) { + next(error); } -}; +} + +module.exports = { generateEnhancedReport }; diff --git a/src/index.js b/src/index.js index 508d686..bd22ffa 100644 --- a/src/index.js +++ b/src/index.js @@ -1,138 +1,82 @@ -/** - * Morty Backend – Express server entry point - */ +'use strict'; + require('dotenv').config(); const express = require('express'); const morgan = require('morgan'); -const { apiLimiter } = require('./middleware/rateLimit'); -const { corsMiddleware, helmetMiddleware } = require('./middleware/security'); +const { initializeFirebase } = require('./config/firebase'); +const { helmetMiddleware, corsMiddleware } = require('./middleware/security'); +const { generalLimiter } = require('./middleware/rateLimit'); +const { errorHandler } = require('./middleware/errorHandler'); const logger = require('./utils/logger'); // Route imports -const authRoutes = require('./routes/auth'); -const profileRoutes = require('./routes/profile'); -const offersRoutes = require('./routes/offers'); const analysisRoutes = require('./routes/analysis'); -const dashboardRoutes = require('./routes/dashboard'); -const ratesRoutes = require('./routes/rates'); -const wizardRoutes = require('./routes/wizard'); -const stripeRoutes = require('./routes/stripe'); -const mortgageCaseRoutes = require('./routes/mortgageCaseRoutes'); -// Cron jobs -const { startRatesCron } = require('./cron/ratesCron'); - - -const wizardPrivateRoutes = require('./routes/wizardPrivateRoutes'); +// Initialise Firebase before anything else +try { + initializeFirebase(); +} catch (err) { + logger.error('Firebase initialisation failed — exiting', { error: err.message }); + process.exit(1); +} -// ── App setup ──────────────────────────────────────────────────────────────── const app = express(); -// Trust the first proxy (Render's reverse proxy) so that req.ip, -// rate-limiters, and other IP-dependent middleware work correctly. -app.set('trust proxy', 1); - - -// Security & utility middleware (order matters) +// ─── Security & parsing middleware ─────────────────────────────────────────── app.use(helmetMiddleware); app.use(corsMiddleware); -app.options('*', corsMiddleware); -app.use(apiLimiter); -app.use(morgan('combined', { stream: { write: (msg) => logger.info(msg.trim()) } })); - -app.use('/api/v1/wizard', wizardPrivateRoutes); -// ── Stripe Webhook Route (MUST be before express.json()) ───────────────────── -// Stripe webhook signature verification requires the raw request body. -// We mount the webhook endpoint with express.raw() BEFORE the global -// express.json() middleware so the body is not parsed as JSON. -app.use( - '/api/v1/stripe/webhook', - express.raw({ type: 'application/json' }) -); - -// Global JSON body parser (for all other routes) app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); -// ── Routes ─────────────────────────────────────────────────────────────────── -app.use('/api/v1/auth', authRoutes); -app.use('/api/v1/auth', mortgageCaseRoutes); -app.use('/api/v1/profile', profileRoutes); -app.use('/api/v1/offers', offersRoutes); -app.use('/api/v1/analysis', analysisRoutes); -app.use('/api/v1/dashboard', dashboardRoutes); - -// Stripe payment routes -app.use('/api/v1/stripe', stripeRoutes); - -// Public routes (no auth required) -app.use('/api/v1/public/rates', ratesRoutes); -app.use('/api/v1/public/wizard', wizardRoutes); - -// Health check -app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() })); - -// 404 handler -app.use((_req, res) => res.status(404).json({ success: false, message: 'Route not found' })); - -// Global error handler -// eslint-disable-next-line no-unused-vars -app.use((err, _req, res, _next) => { - logger.error(err.stack || err.message); +// ─── Logging ───────────────────────────────────────────────────────────────── +if (process.env.NODE_ENV !== 'test') { + app.use(morgan('combined', { + stream: { write: (msg) => logger.info(msg.trim()) }, + })); +} + +// ─── General rate limiting ──────────────────────────────────────────────────── +app.use('/api/', generalLimiter); + +// ─── Health check ───────────────────────────────────────────────────────────── +app.get('/health', (req, res) => { + res.status(200).json({ + success: true, + status: 'healthy', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0', + }); +}); - // Multer file size error - if (err.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ success: false, message: 'File too large. Maximum size is 5 MB.' }); - } +// ─── API Routes ─────────────────────────────────────────────────────────────── +app.use('/api/v1/analysis', analysisRoutes); - const status = err.statusCode || err.status || 500; - const message = err.isOperational ? err.message : 'Internal server error'; - return res.status(status).json({ success: false, message }); +// ─── 404 handler ───────────────────────────────────────────────────────────── +app.use((req, res) => { + res.status(404).json({ + success: false, + error: { + code: 'NOT_FOUND', + message: `Route ${req.method} ${req.path} not found`, + }, + }); }); -// ── Start ──────────────────────────────────────────────────────────────────── -const PORT = process.env.PORT || 5001; - -const start = async () => { - try { - // Initialise Firestore – the module is required here so that any - // credential errors surface at startup rather than on first request. - const db = require('./config/firestore'); - const projectId = - process.env.FIREBASE_PROJECT_ID || - process.env.GCLOUD_PROJECT || - process.env.GOOGLE_CLOUD_PROJECT || - 'unknown'; - logger.info(`Firestore connected (project: ${projectId})`); - - // Attach db to app locals so controllers can access it if needed - app.locals.db = db; - - // Start cron jobs - startRatesCron(); - logger.info('Cron jobs initialised'); - - // Log Stripe configuration status - if (process.env.STRIPE_SECRET_KEY) { - logger.info('Stripe payment integration configured'); - } else { - logger.warn('Stripe payment integration NOT configured (STRIPE_SECRET_KEY missing)'); - } - } catch (err) { - logger.error(`Failed to initialise Firestore: ${err.message}`); - // In production, exit so the process manager can restart with correct creds - if (process.env.NODE_ENV === 'production') { - process.exit(1); - } - } +// ─── Global error handler ───────────────────────────────────────────────────── +app.use(errorHandler); +// ─── Start server ───────────────────────────────────────────────────────────── +const PORT = parseInt(process.env.PORT, 10) || 5000; + +if (process.env.NODE_ENV !== 'test') { app.listen(PORT, () => { - logger.info(`Morty backend running on port ${PORT} in ${process.env.NODE_ENV || 'development'} mode`); + logger.info(`Morty backend running on port ${PORT}`, { + env: process.env.NODE_ENV || 'development', + port: PORT, + }); }); -}; - -start(); +} -module.exports = app; // for testing +module.exports = app; diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 5d66899..ff412cf 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,60 +1,66 @@ -/** - * JWT authentication middleware - * - * Verifies the Bearer JWT in the Authorization header and attaches the - * authenticated user's public profile to `req.user`. - * - * Uses the Firestore-backed userService instead of the legacy Mongoose model. - */ - 'use strict'; -const { verifyAccessToken } = require('../utils/jwt'); -const userService = require('../services/userService'); +const { getAuth } = require('../config/firebase'); +const { getDb } = require('../config/db'); +const COLLECTIONS = require('../config/collections'); +const { UnauthorizedError } = require('../utils/errors'); const logger = require('../utils/logger'); /** - * protect – Express middleware that validates the JWT access token. - * - * On success: attaches `req.user` (public user object) and calls `next()`. - * On failure: returns 401 with an appropriate error message. + * `protect` middleware — verifies Firebase ID token from Authorization header + * and attaches the full user document to `req.user`. * - * @param {import('express').Request} req - * @param {import('express').Response} res - * @param {import('express').NextFunction} next + * Expected header: `Authorization: Bearer ` */ -const protect = async (req, res, next) => { +async function protect(req, res, next) { try { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ success: false, message: 'No token provided' }); + throw new UnauthorizedError('No authentication token provided'); } - const token = authHeader.split(' ')[1]; + const idToken = authHeader.split('Bearer ')[1].trim(); + if (!idToken) { + throw new UnauthorizedError('Invalid authorization header format'); + } - // Verify signature and expiry using the jwt utility - let decoded; + // Verify Firebase ID token + let decodedToken; try { - decoded = verifyAccessToken(token); - } catch (tokenErr) { - if (tokenErr.name === 'TokenExpiredError') { - return res.status(401).json({ success: false, message: 'Token expired' }); - } - return res.status(401).json({ success: false, message: 'Invalid token' }); + decodedToken = await getAuth().verifyIdToken(idToken); + } catch (firebaseError) { + logger.warn('Firebase token verification failed', { + error: firebaseError.message, + code: firebaseError.code, + }); + throw new UnauthorizedError('Invalid or expired authentication token'); } - // Fetch the user from Firestore (returns public profile – no password/refreshToken) - const user = await userService.getUserById(decoded.id); - if (!user) { - return res.status(401).json({ success: false, message: 'User not found' }); + const uid = decodedToken.uid; + + // Fetch user document from Firestore + const db = getDb(); + const userDoc = await db.collection(COLLECTIONS.USERS).doc(uid).get(); + + if (!userDoc.exists) { + throw new UnauthorizedError('User account not found'); } - req.user = user; + const userData = userDoc.data(); + + // Attach user to request + req.user = { + uid, + id: uid, + email: decodedToken.email || userData.email, + ...userData, + }; + + logger.debug('User authenticated', { uid }); next(); - } catch (err) { - logger.warn(`auth middleware error: ${err.message}`); - return res.status(401).json({ success: false, message: 'Authentication failed' }); + } catch (error) { + next(error); } -}; +} module.exports = { protect }; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 1287c36..83fea7b 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -1,205 +1,58 @@ -/** - * Global error handling middleware for the Morty backend. - * Catches all errors passed via next(err) and returns - * consistent JSON error responses. - * - * Must be registered LAST in the Express middleware stack. - * - * Handles: - * - AppError subclasses (operational errors) - * - JWT errors (JsonWebTokenError, TokenExpiredError) - * - Firestore / Google Cloud gRPC errors - * - Multer file-upload errors - * - Body-parser errors (malformed JSON, payload too large) - * - CORS errors - * - Unknown errors (wrapped as InternalServerError) - */ +'use strict'; +const { AppError } = require('../utils/errors'); const logger = require('../utils/logger'); -const { - AppError, - handleJWTError, - handleFirestoreError, -} = require('../utils/errors'); - -/** - * Determine if we should expose error details to the client. - * In production, hide internal error details. - * - * @param {Error} err - The error - * @returns {boolean} - */ -const shouldExposeDetails = (err) => { - if (process.env.NODE_ENV === 'development') return true; - if (err instanceof AppError && err.isOperational) return true; - return false; -}; /** - * Format error response body. - * - * @param {Error} err - The error - * @param {string} requestId - Request ID for tracing - * @returns {Object} JSON response body + * Global Express error handler. + * Must be registered as the last middleware with 4 parameters. */ -const formatErrorResponse = (err, requestId) => { - const expose = shouldExposeDetails(err); - - const response = { +// eslint-disable-next-line no-unused-vars +function errorHandler(err, req, res, next) { + // Log the error + if (err.isOperational) { + logger.warn('Operational error', { + code: err.code, + message: err.message, + statusCode: err.statusCode, + path: req.path, + method: req.method, + }); + } else { + logger.error('Unexpected error', { + message: err.message, + stack: err.stack, + path: req.path, + method: req.method, + }); + } + + // Determine status code and message + const statusCode = err.statusCode || 500; + const code = err.code || 'INTERNAL_ERROR'; + const message = + err.isOperational + ? err.message + : 'An unexpected error occurred. Please try again later.'; + + const body = { success: false, error: { - code: err.errorCode || 'INTERNAL_SERVER_ERROR', - message: expose ? err.message : 'An unexpected error occurred', - ...(expose && err.details && { details: err.details }), - requestId, - timestamp: new Date().toISOString(), + code, + message, }, }; - // Include stack trace in development - if (process.env.NODE_ENV === 'development' && err.stack) { - response.error.stack = err.stack; - } - - return response; -}; - -/** - * Log the error with appropriate severity. - * - * @param {Error} err - The error - * @param {Object} req - Express request object - */ -const logError = (err, req) => { - const logData = { - errorCode: err.errorCode, - statusCode: err.statusCode, - message: err.message, - path: req.path, - method: req.method, - ip: req.ip, - userId: req.user?.id, - requestId: req.id, - isOperational: err.isOperational, - }; - - if (err.statusCode >= 500 || !err.isOperational) { - logger.error('Unhandled error', { ...logData, stack: err.stack }); - } else if (err.statusCode >= 400) { - logger.warn('Client error', logData); - } -}; - -/** - * Handle Multer-specific errors. - * - * @param {Error} err - Multer error - * @returns {AppError|null} - */ -const handleMulterError = (err) => { - const { - PayloadTooLargeError, - ValidationError, - } = require('../utils/errors'); - - if (err.code === 'LIMIT_FILE_SIZE') { - return new PayloadTooLargeError('File size exceeds the 5MB limit'); - } - if (err.code === 'LIMIT_FILE_COUNT') { - return new ValidationError('Too many files uploaded at once'); + if (err.details) { + body.error.details = err.details; } - if (err.code === 'LIMIT_UNEXPECTED_FILE') { - return new ValidationError(`Unexpected file field: ${err.field}`); - } - if (err.code === 'LIMIT_FIELD_KEY') { - return new ValidationError('Field name too long'); - } - if (err.code === 'LIMIT_FIELD_VALUE') { - return new ValidationError('Field value too long'); - } - return null; -}; - -/** - * Global error handler middleware. - * Must have 4 parameters (err, req, res, next) for Express to recognise it. - * - * @param {Error} err - Error object - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @param {Function} next - Express next function - */ -// eslint-disable-next-line no-unused-vars -const globalErrorHandler = (err, req, res, next) => { - let error = err; - if (!(error instanceof AppError)) { - // 1. Try JWT errors - const jwtError = handleJWTError(error); - if (jwtError) { - error = jwtError; - } - // 2. Try Firestore / Google Cloud gRPC errors - else if (handleFirestoreError(error)) { - error = handleFirestoreError(error); - } - // 3. Try Multer errors - else if (error.name === 'MulterError') { - const multerError = handleMulterError(error); - if (multerError) { - error = multerError; - } - } - // 4. Handle CORS errors - else if (error.message && error.message.startsWith('CORS:')) { - const { AuthorizationError } = require('../utils/errors'); - error = new AuthorizationError(error.message); - } - // 5. Handle body parser errors (malformed JSON) - else if (error.type === 'entity.parse.failed') { - const { ValidationError } = require('../utils/errors'); - error = new ValidationError('Invalid JSON in request body'); - } - // 6. Handle payload too large from body parser - else if (error.type === 'entity.too.large') { - const { PayloadTooLargeError } = require('../utils/errors'); - error = new PayloadTooLargeError('Request body too large'); - } - // 7. Unknown errors – wrap as internal server error - else { - const { InternalServerError } = require('../utils/errors'); - const internalError = new InternalServerError( - process.env.NODE_ENV === 'development' ? error.message : 'An unexpected error occurred' - ); - internalError.originalError = error; - error = internalError; - } + // Don't expose stack traces in production + if (process.env.NODE_ENV === 'development' && !err.isOperational) { + body.error.stack = err.stack; } - logError(error, req); - - const statusCode = error.statusCode || 500; - const responseBody = formatErrorResponse(error, req.id); - - return res.status(statusCode).json(responseBody); -}; - -/** - * 404 Not Found handler. - * Must be registered BEFORE the global error handler - * but AFTER all routes. - * - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @param {Function} next - Express next function - */ -const notFoundHandler = (req, res, next) => { - const { NotFoundError } = require('../utils/errors'); - const error = new NotFoundError(`Route ${req.method} ${req.originalUrl}`); - next(error); -}; + return res.status(statusCode).json(body); +} -module.exports = { - globalErrorHandler, - notFoundHandler, -}; +module.exports = { errorHandler }; diff --git a/src/middleware/paidAccess.js b/src/middleware/paidAccess.js index 9f343d2..751ecf3 100644 --- a/src/middleware/paidAccess.js +++ b/src/middleware/paidAccess.js @@ -1,77 +1,39 @@ -/** - * Paid Access Middleware - * - * Checks that the authenticated user has paid for enhanced analysis. - * Must be used AFTER the `protect` middleware (requires `req.user`). - * - * The user's paid status is stored as `paidAnalyses: true` on the - * `users` Firestore document. This flag is set by the payment webhook - * (Stripe) when a successful payment is processed. - * - * Usage: - * router.post('/analysis/enhanced/:offerId', protect, requirePaidAccess, handler); - * - * @module middleware/paidAccess - */ - 'use strict'; -const db = require('../config/firestore'); +const { ForbiddenError } = require('../utils/errors'); const logger = require('../utils/logger'); /** - * requirePaidAccess – Express middleware that verifies the user has - * paid for enhanced analysis features. + * `paidAccess` middleware — ensures the authenticated user has paid for + * enhanced analysis access. * - * On success: calls `next()`. - * On failure: returns 403 with an appropriate error message. + * Checks `req.user.paidAnalyses === true`. + * Must be used AFTER the `protect` middleware. * - * @param {import('express').Request} req - * @param {import('express').Response} res - * @param {import('express').NextFunction} next + * @throws {ForbiddenError} 403 if user has not paid. */ -const requirePaidAccess = async (req, res, next) => { +function paidAccess(req, res, next) { try { - if (!req.user || !req.user.id) { - return res.status(401).json({ - success: false, - message: 'Authentication required', - }); - } - - // Check the user's paid status from Firestore - // We read directly from Firestore to get the latest status - // (the req.user object from the auth middleware may be stale) - const userDoc = await db.collection('users').doc(req.user.id).get(); - - if (!userDoc.exists) { - return res.status(401).json({ - success: false, - message: 'User not found', - }); + if (!req.user) { + // Should not happen if protect runs first, but guard anyway + throw new ForbiddenError('Authentication required before paid access check'); } - const userData = userDoc.data(); - - if (!userData.paidAnalyses) { - logger.info(`paidAccess: user ${req.user.id} attempted to access paid feature without payment`); - return res.status(403).json({ - success: false, - message: 'This feature requires a paid subscription. Please complete payment to access enhanced analysis.', - errorCode: 'PAYMENT_REQUIRED', - paymentUrl: '/paywall', + if (!req.user.paidAnalyses) { + logger.warn('Paid access denied', { + uid: req.user.uid, + paidAnalyses: req.user.paidAnalyses, }); + throw new ForbiddenError( + 'This feature requires a paid subscription. Please unlock the Closer Report to continue.' + ); } - // User has paid – proceed + logger.debug('Paid access granted', { uid: req.user.uid }); next(); - } catch (err) { - logger.error(`paidAccess middleware error: ${err.message}`); - return res.status(500).json({ - success: false, - message: 'Failed to verify payment status', - }); + } catch (error) { + next(error); } -}; +} -module.exports = { requirePaidAccess }; +module.exports = { paidAccess }; diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js index 83ab13a..c190c96 100644 --- a/src/middleware/rateLimit.js +++ b/src/middleware/rateLimit.js @@ -1,24 +1,61 @@ -/** - * Rate limiting middleware - */ +'use strict'; + const rateLimit = require('express-rate-limit'); +const logger = require('../utils/logger'); -/** General API rate limiter */ -const apiLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, +/** + * General API rate limiter. + * 100 requests per minute per IP. + */ +const generalLimiter = rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000, + max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100, standardHeaders: true, legacyHeaders: false, - message: { success: false, message: 'Too many requests, please try again later.' }, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many requests. Please try again later.', + }, + }, + handler: (req, res, next, options) => { + logger.warn('Rate limit exceeded', { + ip: req.ip, + path: req.path, + }); + res.status(429).json(options.message); + }, }); -/** Stricter limiter for auth endpoints */ -const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 20, +/** + * Strict rate limiter for paid/expensive endpoints. + * 5 requests per minute per user (keyed by user ID when available, else IP). + */ +const paidEndpointLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: parseInt(process.env.PAID_RATE_LIMIT_MAX, 10) || 5, standardHeaders: true, legacyHeaders: false, - message: { success: false, message: 'Too many authentication attempts, please try again later.' }, + keyGenerator: (req) => { + // Use authenticated user ID if available, otherwise fall back to IP + return req.user ? req.user.uid : req.ip; + }, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many enhanced report requests. Please wait before trying again.', + }, + }, + handler: (req, res, next, options) => { + logger.warn('Paid endpoint rate limit exceeded', { + uid: req.user ? req.user.uid : null, + ip: req.ip, + path: req.path, + }); + res.status(429).json(options.message); + }, }); -module.exports = { apiLimiter, authLimiter }; +module.exports = { generalLimiter, paidEndpointLimiter }; diff --git a/src/middleware/security.js b/src/middleware/security.js index 8963e19..1877fc2 100644 --- a/src/middleware/security.js +++ b/src/middleware/security.js @@ -1,500 +1,54 @@ -/** - * Security middleware for the Morty backend. - * Implements OWASP Top-10 protections: - * - A01: Broken Access Control (auth middleware) - * - A02: Cryptographic Failures (HTTPS enforcement) - * - A03: Injection (input sanitization) - * - A05: Security Misconfiguration (helmet headers) - * - A06: Vulnerable Components (dependency audit) - * - A07: Auth Failures (rate limiting, lockout) - */ +'use strict'; -const rateLimit = require('express-rate-limit'); const helmet = require('helmet'); const cors = require('cors'); -const logger = require('../utils/logger'); -const { RateLimitError } = require('../utils/errors'); - -// ───────────────────────────────────────────── -// CORS Configuration -// ───────────────────────────────────────────── /** - * Allowed origins for CORS. - * In production, only allow the frontend domain. - * In development, also allow localhost. + * Security middleware configuration. */ -const getAllowedOrigins = () => { - const origins = []; - - // Production frontend URL - if (process.env.FRONTEND_URL) { - origins.push(process.env.FRONTEND_URL); - } - - // GitHub Pages URL - if (process.env.GITHUB_PAGES_URL) { - origins.push(process.env.GITHUB_PAGES_URL); - } - - // Development origins - if (process.env.NODE_ENV !== 'production') { - origins.push( - 'http://localhost:3000', - 'http://localhost:5173', // Vite default - 'http://127.0.0.1:3000', - 'http://127.0.0.1:5173', - 'https://morty-app.onrender.com' - ); - } - - // Always allow the GitHub Pages deployment - origins.push('https://tambeej.github.io'); - - return origins; -}; - -/** - * CORS middleware configuration. - * Restricts cross-origin requests to allowed origins only. - */ -const corsMiddleware = cors({ - origin: (origin, callback) => { - const allowedOrigins = getAllowedOrigins(); - - // Allow requests with no origin (mobile apps, Postman, server-to-server) - if (!origin) { - return callback(null, true); - } - - if (allowedOrigins.includes(origin)) { - return callback(null, true); - } - - logger.logSecurity('CORS_BLOCKED', { origin, allowedOrigins }); - return callback(null, false); - }, - credentials: true, // Allow cookies and Authorization headers - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: [ - 'Content-Type', - 'Authorization', - 'X-Requested-With', - 'X-Request-ID', - 'Accept', - 'Accept-Language', - ], - exposedHeaders: ['X-Request-ID', 'X-RateLimit-Limit', 'X-RateLimit-Remaining'], - maxAge: 86400, // Cache preflight for 24 hours -}); - -// ───────────────────────────────────────────── -// Helmet Security Headers -// ───────────────────────────────────────────── /** - * Helmet middleware with custom CSP configuration. - * Sets security headers to prevent common web vulnerabilities. + * Helmet configuration for HTTP security headers. */ const helmetMiddleware = helmet({ - // Content Security Policy contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for API docs + styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], - mediaSrc: ["'none'"], + mediaSrc: ["'self'"], frameSrc: ["'none'"], - upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null, }, }, - // HTTP Strict Transport Security (HTTPS only) - hsts: { - maxAge: 31536000, // 1 year - includeSubDomains: true, - preload: true, - }, - // Prevent MIME type sniffing - noSniff: true, - // Prevent clickjacking - frameguard: { action: 'deny' }, - // XSS filter (legacy browsers) - xssFilter: true, - // Hide X-Powered-By header - hidePoweredBy: true, - // Allow Google sign-in popup to communicate with the opener window - crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, - // Referrer policy - referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, - // Permissions policy - permittedCrossDomainPolicies: false, -}); - -// ───────────────────────────────────────────── -// Rate Limiting -// ───────────────────────────────────────────── - -/** - * Custom rate limit handler that returns consistent error format. - */ -const rateLimitHandler = (req, res) => { - logger.logSecurity('RATE_LIMIT_EXCEEDED', { - ip: req.ip, - path: req.path, - method: req.method, - userId: req.user?.id, - }); - - const error = new RateLimitError(); - return res.status(429).json(error.toJSON()); -}; - -/** - * General API rate limiter. - * Applies to all API routes. - * 100 requests per 15 minutes per IP. - */ -const generalRateLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, - standardHeaders: true, // Return rate limit info in headers - legacyHeaders: false, - handler: rateLimitHandler, - skip: (req) => { - // Skip rate limiting for health checks - return req.path === '/health' || req.path === '/api/v1/health'; - }, -}); - -/** - * Strict rate limiter for authentication endpoints. - * Prevents brute-force attacks. - * 10 requests per 15 minutes per IP. - */ -const authRateLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 10, - standardHeaders: true, - legacyHeaders: false, - handler: rateLimitHandler, - // Track by IP + email to prevent distributed attacks - keyGenerator: (req) => { - const email = req.body?.email || ''; - return `${req.ip}:${email.toLowerCase()}`; - }, -}); - -/** - * File upload rate limiter. - * Prevents abuse of the upload endpoint. - * 20 uploads per hour per user. - */ -const uploadRateLimit = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour - max: 20, - standardHeaders: true, - legacyHeaders: false, - handler: rateLimitHandler, - keyGenerator: (req) => { - // Rate limit by user ID if authenticated, otherwise by IP - return req.user?.id || req.ip; - }, -}); - -/** - * Analysis rate limiter. - * AI analysis is expensive - limit to 10 per hour per user. - */ -const analysisRateLimit = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour - max: 10, - standardHeaders: true, - legacyHeaders: false, - handler: rateLimitHandler, - keyGenerator: (req) => req.user?.id || req.ip, + crossOriginEmbedderPolicy: false, }); -// ───────────────────────────────────────────── -// Input Sanitization -// ───────────────────────────────────────────── - /** - * Recursively sanitize an object to prevent XSS and NoSQL injection. - * - Removes MongoDB operators ($where, $gt, etc.) from keys - * - Strips HTML tags from string values - * - Limits string length to prevent DoS - * - * @param {*} obj - Value to sanitize - * @param {number} [depth=0] - Current recursion depth - * @returns {*} Sanitized value + * CORS configuration. */ -const sanitizeValue = (obj, depth = 0) => { - // Prevent deep recursion attacks - if (depth > 10) return obj; - - if (typeof obj === 'string') { - // Remove HTML tags (basic XSS prevention) - let sanitized = obj - .replace(/]*>.*?<\/script>/gi, '') - .replace(/<[^>]+>/g, '') - .replace(/javascript:/gi, '') - .replace(/on\w+\s*=/gi, ''); // Remove event handlers - - // Limit string length to prevent DoS - if (sanitized.length > 10000) { - sanitized = sanitized.substring(0, 10000); - } - - return sanitized; - } - - if (Array.isArray(obj)) { - return obj.map((item) => sanitizeValue(item, depth + 1)); - } - - if (obj !== null && typeof obj === 'object') { - const sanitized = {}; - for (const [key, value] of Object.entries(obj)) { - // Remove MongoDB operator keys (NoSQL injection prevention) - if (key.startsWith('$') || key.includes('.')) { - logger.warn('Suspicious key detected in request', { key }); - continue; - } - sanitized[key] = sanitizeValue(value, depth + 1); +const corsOptions = { + origin: (origin, callback) => { + const allowedOrigins = process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(',') + : ['http://localhost:3000', 'http://localhost:5173']; + + // Allow requests with no origin (e.g., mobile apps, curl) + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`CORS policy: origin ${origin} not allowed`)); } - return sanitized; - } - - return obj; -}; - -/** - * Request sanitization middleware. - * Sanitizes req.body, req.params, and req.query. - * Must be applied AFTER body parsing middleware. - */ -const sanitizeRequest = (req, res, next) => { - if (req.body && typeof req.body === 'object') { - req.body = sanitizeValue(req.body); - } - - if (req.params && typeof req.params === 'object') { - req.params = sanitizeValue(req.params); - } - - if (req.query && typeof req.query === 'object') { - req.query = sanitizeValue(req.query); - } - - next(); -}; - -// ───────────────────────────────────────────── -// Request ID Middleware -// ───────────────────────────────────────────── - -/** - * Assign a unique request ID to each request. - * Used for request tracing and log correlation. - */ -const requestId = (req, res, next) => { - const id = - req.headers['x-request-id'] || - `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - req.id = id; - res.setHeader('X-Request-ID', id); - next(); -}; - -// ───────────────────────────────────────────── -// Security Audit Middleware -// ───────────────────────────────────────────── - -/** - * Suspicious patterns to detect in request data. - * These patterns indicate potential injection or attack attempts. - */ -const SUSPICIOUS_PATTERNS = [ - /( { - if (typeof str !== 'string') return false; - return SUSPICIOUS_PATTERNS.some((pattern) => pattern.test(str)); -}; - -/** - * Recursively check an object for suspicious patterns. - * @param {*} obj - Object to check - * @returns {boolean} - */ -const containsSuspiciousContent = (obj) => { - if (typeof obj === 'string') return hasSuspiciousPattern(obj); - if (Array.isArray(obj)) return obj.some(containsSuspiciousContent); - if (obj !== null && typeof obj === 'object') { - return Object.values(obj).some(containsSuspiciousContent); - } - return false; -}; - -/** - * Security audit middleware. - * Logs and flags suspicious requests for monitoring. - * Does NOT block requests (that's done by sanitization). - */ -const securityAudit = (req, res, next) => { - const suspicious = [ - containsSuspiciousContent(req.body), - containsSuspiciousContent(req.query), - containsSuspiciousContent(req.params), - ].some(Boolean); - - if (suspicious) { - logger.logSecurity('SUSPICIOUS_REQUEST', { - ip: req.ip, - method: req.method, - path: req.path, - userId: req.user?.id, - requestId: req.id, - userAgent: req.get('User-Agent'), - }); - } - - next(); -}; - -// ───────────────────────────────────────────── -// File Upload Security -// ───────────────────────────────────────────── - -/** - * Allowed MIME types for file uploads. - */ -const ALLOWED_MIME_TYPES = new Set([ - 'application/pdf', - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/webp', -]); - -/** - * Allowed file extensions for file uploads. - */ -const ALLOWED_EXTENSIONS = new Set(['.pdf', '.png', '.jpg', '.jpeg', '.webp']); - -/** - * Maximum file size: 5MB - */ -const MAX_FILE_SIZE = 5 * 1024 * 1024; - -/** - * Validate uploaded file security. - * Checks MIME type, extension, and file size. - * - * @param {Object} file - Multer file object - * @returns {{ valid: boolean, error?: string }} - */ -const validateUploadedFile = (file) => { - if (!file) { - return { valid: false, error: 'No file provided' }; - } - - // Check MIME type - if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { - return { - valid: false, - error: `File type '${file.mimetype}' is not allowed. Allowed types: PDF, PNG, JPG, WEBP`, - }; - } - - // Check file extension - const path = require('path'); - const ext = path.extname(file.originalname).toLowerCase(); - if (!ALLOWED_EXTENSIONS.has(ext)) { - return { - valid: false, - error: `File extension '${ext}' is not allowed`, - }; - } - - // Check file size - if (file.size > MAX_FILE_SIZE) { - return { - valid: false, - error: `File size ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds maximum allowed size of 5MB`, - }; - } - - // Check for null bytes in filename (path traversal prevention) - if (file.originalname.includes('\0')) { - return { valid: false, error: 'Invalid filename' }; - } - - // Check for path traversal in filename - if (file.originalname.includes('..') || file.originalname.includes('/')) { - return { valid: false, error: 'Invalid filename: path traversal detected' }; - } - - return { valid: true }; + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + maxAge: 86400, // 24 hours preflight cache }; -/** - * Middleware to validate uploaded files after Multer processing. - * Must be placed AFTER Multer middleware. - */ -const validateFileUpload = (req, res, next) => { - const { UnsupportedMediaTypeError, PayloadTooLargeError, ValidationError } = require('../utils/errors'); - - if (!req.file && !req.files) { - return next(); - } - - const files = req.files ? Object.values(req.files).flat() : [req.file]; - - for (const file of files) { - const { valid, error } = validateUploadedFile(file); - if (!valid) { - if (error && error.includes('size')) { - return next(new PayloadTooLargeError(error)); - } - if (error && (error.includes('type') || error.includes('extension'))) { - return next(new UnsupportedMediaTypeError(error)); - } - return next(new ValidationError(error)); - } - } +const corsMiddleware = cors(corsOptions); - next(); -}; - -module.exports = { - corsMiddleware, - helmetMiddleware, - generalRateLimit, - authRateLimit, - uploadRateLimit, - analysisRateLimit, - sanitizeRequest, - requestId, - securityAudit, - validateFileUpload, - validateUploadedFile, - ALLOWED_MIME_TYPES, - ALLOWED_EXTENSIONS, - MAX_FILE_SIZE, -}; +module.exports = { helmetMiddleware, corsMiddleware }; diff --git a/src/middleware/validate.js b/src/middleware/validate.js index c8d2424..e77caa2 100644 --- a/src/middleware/validate.js +++ b/src/middleware/validate.js @@ -1,58 +1,34 @@ -/** - * Joi validation middleware factory. - * - * Creates an Express middleware that validates `req.body` against - * the provided Joi schema. On validation failure, returns a 400 - * response with detailed error messages. - * - * Usage: - * const { validate } = require('../middleware/validate'); - * const { mySchema } = require('../validators/myValidator'); - * router.post('/endpoint', validate(mySchema), controller.handler); - * - * @module middleware/validate - */ - 'use strict'; +const { ValidationError } = require('../utils/errors'); + /** - * Create a validation middleware for the given Joi schema. + * Creates an Express middleware that validates `req.params`, `req.query`, + * or `req.body` against a Joi schema. * - * @param {import('joi').ObjectSchema} schema - Joi validation schema - * @param {string} [property='body'] - Request property to validate ('body', 'query', 'params') - * @returns {import('express').RequestHandler} Express middleware + * @param {import('joi').Schema} schema - Joi schema to validate against. + * @param {'body'|'params'|'query'} [source='body'] - Which part of the request to validate. + * @returns {import('express').RequestHandler} */ -const validate = (schema, property = 'body') => { +function validate(schema, source = 'body') { return (req, res, next) => { - if (!schema || typeof schema.validate !== 'function') { - return next(); - } - - const { error, value } = schema.validate(req[property], { + const { error, value } = schema.validate(req[source], { abortEarly: false, - stripUnknown: false, - allowUnknown: true, + stripUnknown: true, }); if (error) { const details = error.details.map((d) => ({ - field: d.path.join('.'), message: d.message, })); - - - return res.status(400).json({ - success: false, - message: 'Validation failed', - errors: details, - }); + return next(new ValidationError('Validation failed', details)); } - // Replace the request property with the validated (and possibly coerced) value - req[property] = value; - next(); + // Replace the source with the sanitised value + req[source] = value; + return next(); }; -}; +} module.exports = { validate }; diff --git a/src/routes/analysis.js b/src/routes/analysis.js index 6a3b4b8..7c08857 100644 --- a/src/routes/analysis.js +++ b/src/routes/analysis.js @@ -1,37 +1,35 @@ -/** - * Analysis routes - * - * GET /api/v1/analysis/:id – get analysis results for an offer - * POST /api/v1/analysis/enhanced/:offerId – generate enhanced report (paid) - */ +'use strict'; + const express = require('express'); -const router = express.Router(); -const analysisController = require('../controllers/analysisController'); const { protect } = require('../middleware/auth'); -const { requirePaidAccess } = require('../middleware/paidAccess'); -const { validate } = require('../middleware/validate'); -const { enhancedAnalysisSchema } = require('../validators/analysisValidator'); - -// All analysis routes require authentication -router.use(protect); +const { paidAccess } = require('../middleware/paidAccess'); +const { paidEndpointLimiter } = require('../middleware/rateLimit'); +const { validateOfferId } = require('../validators/analysisValidator'); +const { generateEnhancedReport } = require('../controllers/analysisController'); -/** - * @route GET /api/v1/analysis/:id - * @desc Get AI analysis results for a specific offer - * @access Private - */ -router.get('/:id', analysisController.getAnalysis); +const router = express.Router(); /** - * @route POST /api/v1/analysis/enhanced/:offerId - * @desc Generate enhanced analysis report comparing real offer to optimized model - * @access Private + Paid + * POST /api/v1/analysis/:offerId/enhanced + * + * Generate an AI-powered enhanced mortgage analysis report. + * + * Middleware chain: + * 1. protect — Verify Firebase ID token, attach req.user + * 2. paidAccess — Ensure req.user.paidAnalyses === true + * 3. paidEndpointLimiter — Rate limit: 5 req/min per user + * 4. validateOfferId — Validate :offerId param format + * 5. generateEnhancedReport — Controller + * + * @returns {object} { success: true, data: enhancedReport } */ router.post( - '/enhanced/:offerId', - requirePaidAccess, - validate(enhancedAnalysisSchema), - analysisController.getEnhancedAnalysis + '/:offerId/enhanced', + protect, + paidAccess, + paidEndpointLimiter, + validateOfferId, + generateEnhancedReport ); module.exports = router; diff --git a/src/services/aiService.js b/src/services/aiService.js index 4bb0666..86499e2 100644 --- a/src/services/aiService.js +++ b/src/services/aiService.js @@ -1,119 +1,51 @@ -/** - * AI Service - * - * Uses OpenAI Vision API to extract mortgage terms from uploaded files - * and compute analysis/recommendations. - * - * This module has been updated to use the Firestore-backed offerService - * instead of the removed Mongoose Offer model. - */ - 'use strict'; -const { USE_WIZARD_MOCK } = require('../config/devConfig'); + const OpenAI = require('openai'); -const offerService = require('./offerService'); const logger = require('../utils/logger'); -let openai; -if (process.env.OPENAI_API_KEY) { - openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); +let openaiClient; + +function getOpenAIClient() { + if (!openaiClient) { + openaiClient = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); + } + return openaiClient; } /** - * Analyse a mortgage offer document. + * Call OpenAI GPT-4o-mini with JSON mode. * - * Fetches the offer from Firestore, calls OpenAI Vision (or uses a mock - * when OPENAI_API_KEY is not set), then persists the results back to - * Firestore via offerService. - * - * @param {string} offerId - Firestore document ID of the Offer - * @returns {Promise} Updated offer document + * @param {string} systemPrompt + * @param {string} userPrompt + * @param {object} [options] + * @param {number} [options.temperature=0.4] + * @param {number} [options.maxTokens=2000] + * @returns {Promise} Parsed JSON response. */ -exports.analyzeOffer = async (offerId) => { - const offer = await offerService.findById(offerId); - if (!offer) throw new Error(`Offer ${offerId} not found`); - - try { - if (!openai || (USE_WIZARD_MOCK)) { - // Mock analysis when OpenAI key is not configured (dev/test) - logger.warn('OPENAI_API_KEY not set – using mock analysis'); - - const extractedData = { - bank: offer.extractedData.bank || 'Unknown Bank', - amount: 1200000, - rate: 3.8, - term: 25, - }; - const analysis = { - recommendedRate: 3.4, - savings: 48000, - aiReasoning: - 'Mock analysis: The offered rate of 3.8% is above the current market average of 3.4%. ' + - 'Negotiating to 3.4% would save approximately ₪48,000 over the loan term.', - }; - - return offerService.saveAnalysisResults(offerId, extractedData, analysis); - } +async function callGPT(systemPrompt, userPrompt, options = {}) { + const { temperature = 0.4, maxTokens = 2000 } = options; + + const client = getOpenAIClient(); + + const response = await client.chat.completions.create({ + model: 'gpt-4o-mini', + temperature, + max_tokens: maxTokens, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }); + + const content = response.choices[0]?.message?.content; + if (!content) { + throw new Error('Empty response from OpenAI'); + } - // ── Real OpenAI Vision analysis ────────────────────────────────────────── - const prompt = `You are a mortgage analysis expert. Analyze this mortgage offer document image. -Extract the following information in JSON format: -{ - "bank": "bank name", - "amount": loan amount in ILS (number), - "rate": annual interest rate as percentage (number), - "term": loan term in years (number), - "recommendedRate": your recommended competitive rate (number), - "savings": estimated lifetime savings at recommended rate in ILS (number), - "reasoning": brief explanation of your analysis + return JSON.parse(content); } -If you cannot extract a value, use null.`; - - const response = await openai.chat.completions.create({ - model: 'gpt-4o', - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: prompt }, - { type: 'image_url', image_url: { url: offer.originalFile.url } }, - ], - }, - ], - max_tokens: 500, - }); - const content = response.choices[0].message.content; - - // Extract JSON from the response - const jsonMatch = content.match(/\{[\s\S]*\}/); - if (!jsonMatch) throw new Error('No JSON found in AI response'); - - const parsed = JSON.parse(jsonMatch[0]); - - const extractedData = { - bank: parsed.bank || offer.extractedData.bank || '', - amount: parsed.amount ?? null, - rate: parsed.rate ?? null, - term: parsed.term ?? null, - }; - const analysis = { - recommendedRate: parsed.recommendedRate ?? null, - savings: parsed.savings ?? null, - aiReasoning: parsed.reasoning || '', - }; - - const updated = await offerService.saveAnalysisResults(offerId, extractedData, analysis); - logger.info(`aiService.analyzeOffer: offer ${offerId} analyzed successfully`); - return updated; - } catch (err) { - logger.error(`aiService.analyzeOffer error for ${offerId}: ${err.message}`); - // Mark the offer as errored in Firestore - try { - await offerService.markOfferError(offerId); - } catch (markErr) { - logger.error(`aiService.analyzeOffer: failed to mark offer ${offerId} as error: ${markErr.message}`); - } - throw err; - } -}; +module.exports = { callGPT, getOpenAIClient }; diff --git a/src/services/offerService.js b/src/services/offerService.js index bd097b5..fa675fd 100644 --- a/src/services/offerService.js +++ b/src/services/offerService.js @@ -1,504 +1,58 @@ -/** - * Offer Service – Firestore CRUD, Cloudinary upload, and AI analysis. - * - * All interactions with the `offers` Firestore collection are centralised here. - * Controllers should use this service rather than touching Firestore directly. - * - * Document shape stored in Firestore: - * { - * id: string (Firestore document ID, also stored as field) - * userId: string (required, indexed with createdAt desc) - * originalFile: { - * url: string (Cloudinary secure URL) - * mimetype: string - * } - * extractedData: { - * bank: string (default '') - * amount: number|null - * rate: number|null - * term: number|null - * } - * analysis: { - * recommendedRate: number|null - * savings: number|null - * aiReasoning: string (default '') - * } - * status: 'pending'|'analyzed'|'error' (default 'pending') - * createdAt: ISO string - * updatedAt: ISO string - * } - * - * Indexes required in Firestore console: - * Collection: offers - * Fields: userId ASC, createdAt DESC - */ - 'use strict'; -const db = require('../config/firestore'); -const cloudinary = require('../config/cloudinary'); +const { getDb } = require('../config/db'); +const COLLECTIONS = require('../config/collections'); +const { NotFoundError, ForbiddenError } = require('../utils/errors'); const logger = require('../utils/logger'); -/** Firestore collection name */ -const COLLECTION = 'offers'; - -/** Valid offer status values */ -const OFFER_STATUSES = Object.freeze(['pending', 'analyzed', 'error']); - -/** Reference to the offers collection */ -const offersRef = () => db.collection(COLLECTION); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/** - * Convert a Firestore DocumentSnapshot to a plain JS object. - * Returns null when the document does not exist. - * - * @param {FirebaseFirestore.DocumentSnapshot} snap - * @returns {Object|null} - */ -function snapToDoc(snap) { - if (!snap.exists) return null; - return { id: snap.id, ...snap.data() }; -} - -/** - * Build a normalised offer data object with safe defaults. - * - * @param {string} userId - Firestore user document ID - * @param {Object} originalFile - { url: string, mimetype: string } - * @param {string} [bankName] - Optional bank name hint from the client - * @returns {Object} Normalised offer document ready for Firestore - */ -function buildOfferData(userId, originalFile, bankName = '') { - const now = new Date().toISOString(); - return { - userId, - originalFile: { - url: originalFile.url, - mimetype: originalFile.mimetype, - }, - extractedData: { - bank: bankName || '', - amount: null, - rate: null, - term: null, - }, - analysis: { - recommendedRate: null, - savings: null, - aiReasoning: '', - }, - status: 'pending', - createdAt: now, - updatedAt: now, - }; -} - -/** - * Return the offer document as a plain object suitable for API responses. - * Currently a pass-through (no sensitive fields), kept for symmetry with - * other services and future extensibility. - * - * @param {Object|null} doc - Raw Firestore document data - * @returns {Object|null} - */ -function toPublicOffer(doc) { - if (!doc) return null; - return { ...doc }; -} - -// ── Read operations ─────────────────────────────────────────────────────────── - -/** - * Find an offer by its Firestore document ID. - * Does NOT enforce userId ownership – callers must check ownership if needed. - * - * @param {string} offerId - Firestore document ID - * @returns {Promise} Offer document or null - */ -async function findById(offerId) { - if (!offerId) return null; - try { - const snap = await offersRef().doc(offerId).get(); - return toPublicOffer(snapToDoc(snap)); - } catch (err) { - logger.error(`offerService.findById error (id=${offerId}): ${err.message}`); - throw err; - } -} - /** - * Find an offer by ID and verify it belongs to the given user. + * Find an offer by its ID and verify it belongs to the given user. * - * @param {string} offerId - Firestore document ID - * @param {string} userId - Firestore user document ID - * @returns {Promise} Offer document or null if not found / not owned + * @param {string} offerId - Firestore document ID of the offer. + * @param {string} userId - UID of the authenticated user. + * @returns {Promise<{id: string, ...offerData}>} The offer document data. + * @throws {NotFoundError} If the offer does not exist. + * @throws {ForbiddenError} If the offer belongs to a different user. */ async function findByIdAndUserId(offerId, userId) { - if (!offerId || !userId) return null; - try { - const offer = await findById(offerId); - if (!offer || offer.userId !== userId) return null; - return offer; - } catch (err) { - logger.error(`offerService.findByIdAndUserId error (id=${offerId}, userId=${userId}): ${err.message}`); - throw err; - } -} + const db = getDb(); + const offerRef = db.collection(COLLECTIONS.OFFERS).doc(offerId); + const offerDoc = await offerRef.get(); -/** - * List all offers for a user, sorted by createdAt descending. - * - * Requires a composite Firestore index on (userId ASC, createdAt DESC). - * - * @param {string} userId - Firestore user document ID - * @param {Object} [opts] - Pagination options - * @param {number} [opts.limit=10] - Max documents to return (capped at 50) - * @param {number} [opts.page=1] - 1-based page number - * @returns {Promise<{ offers: Object[], total: number }>} - */ -async function listOffersByUser(userId, { limit = 10, page = 1 } = {}) { - if (!userId) return { offers: [], total: 0 }; - - const safeLimit = Math.min(50, Math.max(1, Number(limit) || 10)); - const safePage = Math.max(1, Number(page) || 1); - const offset = (safePage - 1) * safeLimit; - - try { - // Firestore does not support native offset pagination efficiently; - // we fetch all matching docs and slice in memory for simplicity. - // For large datasets, cursor-based pagination should be used instead. - const snap = await offersRef() - .where('userId', '==', userId) - .orderBy('createdAt', 'desc') - .get(); - - const allOffers = snap.docs.map((d) => toPublicOffer({ id: d.id, ...d.data() })); - const total = allOffers.length; - const offers = allOffers.slice(offset, offset + safeLimit); - - return { offers, total }; - } catch (err) { - logger.error(`offerService.listOffersByUser error (userId=${userId}): ${err.message}`); - throw err; + if (!offerDoc.exists) { + logger.warn('Offer not found', { offerId, userId }); + throw new NotFoundError(`Offer with ID '${offerId}' not found`); } -} -/** - * Return the N most recent offers for a user (no pagination). - * - * @param {string} userId - Firestore user document ID - * @param {number} [n=5] - Number of offers to return - * @returns {Promise} - */ -async function getRecentOffers(userId, n = 5) { - if (!userId) return []; - try { - const snap = await offersRef() - .where('userId', '==', userId) - .orderBy('createdAt', 'desc') - .limit(n) - .get(); - - return snap.docs.map((d) => toPublicOffer({ id: d.id, ...d.data() })); - } catch (err) { - logger.error(`offerService.getRecentOffers error (userId=${userId}): ${err.message}`); - throw err; - } -} - -/** - * Count offers for a user, optionally filtered by status. - * - * @param {string} userId - Firestore user document ID - * @param {string} [status] - Optional status filter - * @returns {Promise} - */ -async function countOffersByUser(userId, status) { - if (!userId) return 0; - try { - let query = offersRef().where('userId', '==', userId); - if (status && OFFER_STATUSES.includes(status)) { - query = query.where('status', '==', status); - } - const snap = await query.get(); - return snap.size; - } catch (err) { - logger.error(`offerService.countOffersByUser error (userId=${userId}): ${err.message}`); - throw err; - } -} + const offerData = offerDoc.data(); -/** - * Compute aggregate stats for a user's offers. - * - * @param {string} userId - Firestore user document ID - * @returns {Promise<{ total: number, pending: number, analyzed: number, error: number, savingsTotal: number }>} - */ -async function getOfferStats(userId) { - if (!userId) { - return { total: 0, pending: 0, analyzed: 0, error: 0, savingsTotal: 0 }; - } - try { - const snap = await offersRef().where('userId', '==', userId).get(); - let pending = 0; - let analyzed = 0; - let error = 0; - let savingsTotal = 0; - - snap.docs.forEach((d) => { - const data = d.data(); - if (data.status === 'pending') pending++; - if (data.status === 'analyzed') analyzed++; - if (data.status === 'error') error++; - if (data.analysis && typeof data.analysis.savings === 'number') { - savingsTotal += data.analysis.savings; - } + // Ownership check + if (offerData.userId !== userId) { + logger.warn('Offer ownership mismatch', { + offerId, + requestingUser: userId, + ownerUser: offerData.userId, }); - - return { total: snap.size, pending, analyzed, error, savingsTotal }; - } catch (err) { - logger.error(`offerService.getOfferStats error (userId=${userId}): ${err.message}`); - throw err; + throw new ForbiddenError('You do not have permission to access this offer'); } -} -// ── Write operations ────────────────────────────────────────────────────────── - -/** - * Create a new offer document in Firestore. - * - * @param {string} userId - Firestore user document ID - * @param {Object} originalFile - { url: string, mimetype: string } - * @param {string} [bankName] - Optional bank name hint - * @returns {Promise} The created offer document - */ -async function createOffer(userId, originalFile, bankName = '') { - if (!userId) throw new Error('userId is required for createOffer'); - if (!originalFile || !originalFile.url) { - throw new Error('originalFile.url is required for createOffer'); - } - - const offerData = buildOfferData(userId, originalFile, bankName); - - try { - const docRef = offersRef().doc(); - const docWithId = { id: docRef.id, ...offerData }; - await docRef.set(docWithId); - logger.info(`offerService.createOffer: created offer ${docRef.id} for user ${userId}`); - return toPublicOffer(docWithId); - } catch (err) { - logger.error(`offerService.createOffer error (userId=${userId}): ${err.message}`); - throw err; - } + return { id: offerId, ...offerData }; } /** - * Update arbitrary fields on an offer document. + * Update the analysis.enhanced field of an offer document. * - * Always sets `updatedAt` to the current ISO timestamp. - * - * @param {string} offerId - Firestore document ID - * @param {Object} updates - Fields to update - * @returns {Promise} Updated offer document - */ -async function updateOffer(offerId, updates) { - if (!offerId) throw new Error('offerId is required for updateOffer'); - - const now = new Date().toISOString(); - const safeUpdates = { ...updates, updatedAt: now }; - - // Prevent overwriting immutable fields - delete safeUpdates.id; - delete safeUpdates.userId; - delete safeUpdates.createdAt; - - try { - await offersRef().doc(offerId).update(safeUpdates); - const updated = await findById(offerId); - return toPublicOffer(updated); - } catch (err) { - logger.error(`offerService.updateOffer error (id=${offerId}): ${err.message}`); - throw err; - } -} - -/** - * Update the status of an offer. - * - * @param {string} offerId - Firestore document ID - * @param {string} status - New status ('pending'|'analyzed'|'error') - * @returns {Promise} Updated offer document - */ -async function updateOfferStatus(offerId, status) { - if (!OFFER_STATUSES.includes(status)) { - throw new Error(`Invalid offer status: ${status}. Must be one of: ${OFFER_STATUSES.join(', ')}`); - } - return updateOffer(offerId, { status }); -} - -/** - * Save AI-extracted data and analysis results to an offer document. - * Sets status to 'analyzed' on success. - * - * @param {string} offerId - Firestore document ID - * @param {Object} extractedData - { bank, amount, rate, term } - * @param {Object} analysis - { recommendedRate, savings, aiReasoning } - * @returns {Promise} Updated offer document - */ -async function saveAnalysisResults(offerId, extractedData, analysis) { - if (!offerId) throw new Error('offerId is required for saveAnalysisResults'); - - const updates = { - extractedData: { - bank: extractedData.bank || '', - amount: extractedData.amount ?? null, - rate: extractedData.rate ?? null, - term: extractedData.term ?? null, - }, - analysis: { - recommendedRate: analysis.recommendedRate ?? null, - savings: analysis.savings ?? null, - aiReasoning: analysis.aiReasoning || '', - }, - status: 'analyzed', - }; - - try { - const updated = await updateOffer(offerId, updates); - logger.info(`offerService.saveAnalysisResults: saved analysis for offer ${offerId}`); - return updated; - } catch (err) { - logger.error(`offerService.saveAnalysisResults error (id=${offerId}): ${err.message}`); - throw err; - } -} - -/** - * Mark an offer as errored (e.g., AI analysis failed). - * - * @param {string} offerId - Firestore document ID - * @returns {Promise} Updated offer document - */ -async function markOfferError(offerId) { - return updateOfferStatus(offerId, 'error'); -} - -/** - * Delete an offer document from Firestore. - * Optionally deletes the associated Cloudinary file. - * - * @param {string} offerId - Firestore document ID - * @param {string} userId - Must match offer.userId (ownership check) - * @param {boolean} [deleteFile=true] - Whether to delete the Cloudinary file + * @param {string} offerId - Firestore document ID. + * @param {object} enhancedReport - The enhanced report data to store. * @returns {Promise} */ -async function deleteOffer(offerId, userId, deleteFile = true) { - if (!offerId) throw new Error('offerId is required for deleteOffer'); - if (!userId) throw new Error('userId is required for deleteOffer'); - - const offer = await findByIdAndUserId(offerId, userId); - if (!offer) { - const err = new Error('Offer not found or access denied'); - err.statusCode = 404; - throw err; - } - - try { - // Attempt to delete the Cloudinary file (non-fatal if it fails) - if (deleteFile && offer.originalFile && offer.originalFile.url) { - try { - // Extract public_id from Cloudinary URL - // URL format: https://res.cloudinary.com//raw/upload// - const urlParts = offer.originalFile.url.split('/'); - const uploadIndex = urlParts.indexOf('upload'); - if (uploadIndex !== -1) { - // Skip version segment (v1234567890) if present - let publicIdParts = urlParts.slice(uploadIndex + 1); - if (publicIdParts[0] && /^v\d+$/.test(publicIdParts[0])) { - publicIdParts = publicIdParts.slice(1); - } - const publicId = publicIdParts.join('/').replace(/\.[^/.]+$/, ''); - if (publicId) { - await cloudinary.uploader.destroy(publicId, { resource_type: 'raw' }); - logger.info(`offerService.deleteOffer: deleted Cloudinary file ${publicId}`); - } - } - } catch (cloudErr) { - logger.warn(`offerService.deleteOffer: Cloudinary delete failed for offer ${offerId}: ${cloudErr.message}`); - } - } - - await offersRef().doc(offerId).delete(); - logger.info(`offerService.deleteOffer: deleted offer ${offerId} for user ${userId}`); - } catch (err) { - logger.error(`offerService.deleteOffer error (id=${offerId}): ${err.message}`); - throw err; - } -} - -// ── Upload helper ───────────────────────────────────────────────────────────── - -/** - * Upload a file buffer to Cloudinary and return the result. - * - * Uses a stream-based upload so the buffer is never written to disk. - * - * @param {Buffer} buffer - File buffer (from multer memoryStorage) - * @param {string} mimetype - MIME type of the file - * @returns {Promise<{ url: string, publicId: string }>} - */ -async function uploadFileToCloudinary(buffer, mimetype) { - if (!buffer) throw new Error('buffer is required for uploadFileToCloudinary'); - - return new Promise((resolve, reject) => { - const resourceType = mimetype === 'application/pdf' ? 'raw' : 'image'; - - const uploadStream = cloudinary.uploader.upload_stream( - { - folder: 'morty/offers', - resource_type: resourceType, - }, - (error, result) => { - if (error) { - logger.error(`offerService.uploadFileToCloudinary: Cloudinary error: ${error.message}`); - return reject(new Error(`Cloudinary upload failed: ${error.message}`)); - } - resolve({ - url: result.secure_url, - publicId: result.public_id, - }); - } - ); - - uploadStream.end(buffer); +async function updateEnhancedAnalysis(offerId, enhancedReport) { + const db = getDb(); + await db.collection(COLLECTIONS.OFFERS).doc(offerId).update({ + 'analysis.enhanced': enhancedReport, + updatedAt: new Date().toISOString(), }); + logger.info('Enhanced analysis stored', { offerId }); } -// ── Exports ─────────────────────────────────────────────────────────────────── - -module.exports = { - // Constants - OFFER_STATUSES, - // Read - findById, - findByIdAndUserId, - listOffersByUser, - getRecentOffers, - countOffersByUser, - getOfferStats, - // Write - createOffer, - updateOffer, - updateOfferStatus, - saveAnalysisResults, - markOfferError, - deleteOffer, - // Upload - uploadFileToCloudinary, - // Internal helpers (exported for testing) - buildOfferData, - toPublicOffer, - snapToDoc, -}; +module.exports = { findByIdAndUserId, updateEnhancedAnalysis }; diff --git a/src/services/portfolioService.js b/src/services/portfolioService.js new file mode 100644 index 0000000..46a19cd --- /dev/null +++ b/src/services/portfolioService.js @@ -0,0 +1,52 @@ +'use strict'; + +const { getDb } = require('../config/db'); +const COLLECTIONS = require('../config/collections'); +const logger = require('../utils/logger'); + +/** + * Retrieve the user's latest mortgage portfolio. + * + * Strategy: + * 1. Check the user document for an embedded `portfolio` field (wizard output). + * 2. Fall back to the most recently updated document in the `portfolios` collection. + * 3. Return null if no portfolio exists. + * + * @param {string} userId - UID of the authenticated user. + * @returns {Promise} Portfolio data or null. + */ +async function getUserPortfolio(userId) { + const db = getDb(); + + // 1. Check user document for embedded portfolio + const userDoc = await db.collection(COLLECTIONS.USERS).doc(userId).get(); + if (userDoc.exists) { + const userData = userDoc.data(); + if (userData.portfolio && Object.keys(userData.portfolio).length > 0) { + logger.debug('Portfolio found in user document', { userId }); + return userData.portfolio; + } + } + + // 2. Query portfolios collection for the user's latest portfolio + const portfoliosSnap = await db + .collection(COLLECTIONS.PORTFOLIOS) + .where('userId', '==', userId) + .orderBy('updatedAt', 'desc') + .limit(1) + .get(); + + if (!portfoliosSnap.empty) { + const portfolioDoc = portfoliosSnap.docs[0]; + logger.debug('Portfolio found in portfolios collection', { + userId, + portfolioId: portfolioDoc.id, + }); + return { id: portfolioDoc.id, ...portfolioDoc.data() }; + } + + logger.debug('No portfolio found for user', { userId }); + return null; +} + +module.exports = { getUserPortfolio }; diff --git a/src/services/reportService.js b/src/services/reportService.js index dc9cb71..88e1bac 100644 --- a/src/services/reportService.js +++ b/src/services/reportService.js @@ -1,796 +1,439 @@ -/** - * Report Service – Enhanced OCR Analysis for Paid Users - * - * Compares a user's real bank offer (extracted via OCR from offerService) - * against their selected optimized portfolio model (from wizardService). - * - * Generates a comprehensive AI-powered report containing: - * 1. **OCR vs Model Comparison**: Track-by-track rate comparison showing - * where the bank offer is better/worse than the optimized model. - * 2. **Mortgage Tricks**: Strategic suggestions like the "Enticement Track" - * (מסלול פיתיון) – taking a high-interest track to lower others and - * refinancing later. - * 3. **Negotiation Script**: A personalized, word-for-word Hebrew script - * for the bank meeting, referencing specific rates and savings. - * 4. **Strategic Insights**: Explanations of the "Why" behind suggestions, - * matching tracks to expected future funds, risk profile, etc. - * - * The report is stored in the offer document under `analysis.enhanced` - * for future retrieval. - * - * @module reportService - */ - 'use strict'; -const OpenAI = require('openai'); -const offerService = require('./offerService'); -const ratesService = require('./ratesService'); +const { callGPT } = require('./aiService'); +const { updateEnhancedAnalysis } = require('./offerService'); const logger = require('../utils/logger'); -let openai; -if (process.env.OPENAI_API_KEY) { - openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); -} - -// ── Constants ───────────────────────────────────────────────────────────────── - -/** Track type labels in Hebrew for report generation */ -const TRACK_LABELS_HE = Object.freeze({ - fixed: 'קבועה לא צמודה (קל"צ)', - cpi: 'צמוד מדד', - prime: 'פריים', - variable: 'משתנה לא צמודה', -}); - -/** Track type labels in English */ -const TRACK_LABELS_EN = Object.freeze({ - fixed: 'Fixed (Non-Indexed)', - cpi: 'CPI-Indexed', - prime: 'Prime', - variable: 'Variable (Non-Indexed)', -}); - -// ── Main Entry Point ────────────────────────────────────────────────────────── +// ─── Sanitisation helpers ──────────────────────────────────────────────────── /** - * Generate an enhanced analysis report comparing a real bank offer - * to the user's selected optimized portfolio. - * - * Flow: - * 1. Fetch the offer document (must be analyzed via OCR already) - * 2. Validate the portfolio data - * 3. Fetch current BOI rates for context - * 4. Build the comparison data structure - * 5. Generate AI-powered report (tricks, script, insights) - * 6. Store the enhanced report in the offer document - * 7. Return the complete report - * - * @param {string} offerId - Firestore document ID of the analyzed offer - * @param {string} userId - Authenticated user's ID (for ownership check) - * @param {object} portfolio - The user's selected portfolio from the wizard - * @param {string} portfolio.id - Portfolio scenario type (e.g., 'market_standard') - * @param {string} portfolio.name - Portfolio name - * @param {string} [portfolio.nameHe] - Hebrew name - * @param {number} portfolio.termYears - Loan term in years - * @param {Array} portfolio.tracks - Track breakdown - * @param {number} portfolio.monthlyRepayment - Monthly payment (₪) - * @param {number} portfolio.totalCost - Total cost over loan term (₪) - * @param {number} portfolio.totalInterest - Total interest paid (₪) - * @returns {Promise} Enhanced analysis report + * Sanitise a trick object returned from AI to ensure safe, expected shape. + * @param {*} trick + * @returns {object} */ -async function generateEnhancedReport(offerId, userId, portfolio) { - const startTime = Date.now(); - - // 1. Fetch and validate the offer - const offer = await offerService.findByIdAndUserId(offerId, userId); - if (!offer) { - const err = new Error('Offer not found or access denied'); - err.statusCode = 404; - throw err; - } - - if (offer.status !== 'analyzed') { - const err = new Error( - 'Offer must be analyzed via OCR before generating an enhanced report. ' + - 'Current status: ' + offer.status - ); - err.statusCode = 400; - throw err; - } - - // 2. Validate portfolio data - validatePortfolio(portfolio); - - // 3. Fetch current BOI rates for context - let currentRates; - try { - currentRates = await ratesService.getCurrentAverages(); - } catch (err) { - logger.warn(`reportService: failed to get current rates: ${err.message}`); - currentRates = { fixed: 4.65, cpi: 3.15, prime: 6.05, variable: 4.95 }; - } - - // 4. Build comparison data - const comparison = buildComparison(offer, portfolio, currentRates); - - // 5. Generate AI-powered report - let aiReport; - try { - aiReport = await generateAIReport(offer, portfolio, comparison, currentRates); - logger.info(`reportService: AI report generated for offer ${offerId}`); - } catch (err) { - logger.warn(`reportService: AI report generation failed, using rule-based: ${err.message}`); - aiReport = generateRuleBasedReport(offer, portfolio, comparison, currentRates); - } - - // 6. Build the complete enhanced report - const elapsed = Date.now() - startTime; - const enhancedReport = { - offerId, - portfolioId: portfolio.id, - portfolioName: portfolio.name, - portfolioNameHe: portfolio.nameHe || portfolio.name, - generatedAt: new Date().toISOString(), - processingTimeMs: elapsed, - comparison, - tricks: aiReport.tricks || [], - negotiationScript: aiReport.negotiationScript || '', - insights: aiReport.insights || [], - summary: aiReport.summary || '', - summaryHe: aiReport.summaryHe || '', - }; - - // 7. Store the enhanced report in the offer document - try { - await offerService.updateOffer(offerId, { - 'analysis.enhanced': enhancedReport, - portfolioId: portfolio.id, - }); - logger.info(`reportService: enhanced report stored for offer ${offerId}`); - } catch (err) { - logger.error(`reportService: failed to store enhanced report: ${err.message}`); - // Non-fatal – still return the report even if storage fails - } - - return enhancedReport; -} - -// ── Comparison Builder ──────────────────────────────────────────────────────── - -/** - * Build a structured comparison between the bank offer and the optimized portfolio. - * - * @param {object} offer - The analyzed offer document - * @param {object} portfolio - The selected portfolio - * @param {object} currentRates - Current BOI average rates - * @returns {object} Comparison data structure - */ -function buildComparison(offer, portfolio, currentRates) { - const extracted = offer.extractedData || {}; - const offerRate = extracted.rate; - const offerAmount = extracted.amount; - const offerTerm = extracted.term; - const offerBank = extracted.bank || 'לא ידוע'; - - // Calculate portfolio weighted average rate - const portfolioWeightedRate = calculateWeightedRate(portfolio.tracks); - - // Rate difference (positive = bank offer is more expensive) - const rateDifference = offerRate != null && portfolioWeightedRate != null - ? Math.round((offerRate - portfolioWeightedRate) * 100) / 100 - : null; - - // Estimate potential savings - const potentialSavings = estimateSavings( - offerAmount || portfolio.tracks.reduce((sum, t) => sum + (t.amount || 0), 0), - offerRate, - portfolioWeightedRate, - offerTerm || portfolio.termYears - ); - - // Track-by-track comparison (where possible) - const trackComparisons = buildTrackComparisons(offer, portfolio, currentRates); - +function sanitizeTrick(trick) { + if (!trick || typeof trick !== 'object') return null; return { - bankOffer: { - bank: offerBank, - amount: offerAmount, - rate: offerRate, - term: offerTerm, - recommendedRate: offer.analysis?.recommendedRate || null, - }, - optimizedModel: { - name: portfolio.name, - nameHe: portfolio.nameHe || portfolio.name, - termYears: portfolio.termYears, - monthlyRepayment: portfolio.monthlyRepayment, - totalCost: portfolio.totalCost, - totalInterest: portfolio.totalInterest, - weightedRate: portfolioWeightedRate, - tracks: portfolio.tracks.map((t) => ({ - type: t.type, - name: TRACK_LABELS_HE[t.type] || t.type, - percentage: t.percentage, - rate: t.rate, - rateDisplay: t.rateDisplay || `${t.rate}%`, - })), - }, - rateDifference, - potentialMonthlySavings: potentialSavings.monthly, - potentialTotalSavings: potentialSavings.total, - potentialInterestSavings: potentialSavings.interest, - trackComparisons, - boiAverages: currentRates, - verdict: rateDifference != null - ? (rateDifference > 0.3 ? 'significantly_worse' - : rateDifference > 0 ? 'slightly_worse' - : rateDifference > -0.3 ? 'comparable' - : 'better_than_model') - : 'insufficient_data', + nameHe: String(trick.nameHe || '').slice(0, 200), + nameEn: String(trick.nameEn || '').slice(0, 200), + descriptionHe: String(trick.descriptionHe || '').slice(0, 1000), + descriptionEn: String(trick.descriptionEn || '').slice(0, 1000), + applicability: ['high', 'medium', 'low'].includes(trick.applicability) + ? trick.applicability + : 'medium', + riskLevel: ['low', 'medium', 'high'].includes(trick.riskLevel) + ? trick.riskLevel + : 'medium', + potentialSavings: + typeof trick.potentialSavings === 'number' && trick.potentialSavings >= 0 + ? Math.round(trick.potentialSavings) + : null, }; } /** - * Calculate the weighted average rate across portfolio tracks. - * - * @param {Array} tracks - Portfolio tracks with percentage and rate - * @returns {number|null} Weighted average rate, or null if no valid tracks + * Sanitise an insight object returned from AI. + * @param {*} insight + * @returns {object} */ -function calculateWeightedRate(tracks) { - if (!tracks || tracks.length === 0) return null; - - let totalWeight = 0; - let weightedSum = 0; - - for (const track of tracks) { - if (track.rate != null && track.percentage != null) { - weightedSum += track.rate * (track.percentage / 100); - totalWeight += track.percentage / 100; - } - } - - if (totalWeight === 0) return null; - return Math.round((weightedSum / totalWeight) * 100) / 100; -} - -/** - * Estimate potential savings between the bank offer rate and the portfolio rate. - * - * @param {number} loanAmount - Loan principal - * @param {number|null} offerRate - Bank offer rate (%) - * @param {number|null} portfolioRate - Portfolio weighted rate (%) - * @param {number} termYears - Loan term in years - * @returns {{ monthly: number|null, total: number|null, interest: number|null }} - */ -function estimateSavings(loanAmount, offerRate, portfolioRate, termYears) { - if (offerRate == null || portfolioRate == null || !loanAmount || !termYears) { - return { monthly: null, total: null, interest: null }; - } - - const months = termYears * 12; - - const offerMonthly = calculatePMT(loanAmount, offerRate / 100 / 12, months); - const portfolioMonthly = calculatePMT(loanAmount, portfolioRate / 100 / 12, months); - - const monthlySavings = Math.round(offerMonthly - portfolioMonthly); - const totalSavings = monthlySavings * months; - const interestSavings = totalSavings; // Simplified: all savings are interest savings - +function sanitizeInsight(insight) { + if (!insight || typeof insight !== 'object') return null; + const validIcons = ['trending-down', 'check-circle', 'target', 'calendar', 'shield', 'info']; return { - monthly: Math.max(0, monthlySavings), - total: Math.max(0, totalSavings), - interest: Math.max(0, interestSavings), + titleHe: String(insight.titleHe || '').slice(0, 200), + titleEn: String(insight.titleEn || '').slice(0, 200), + bodyHe: String(insight.bodyHe || '').slice(0, 1000), + bodyEn: String(insight.bodyEn || '').slice(0, 1000), + icon: validIcons.includes(insight.icon) ? insight.icon : 'info', }; } -/** - * Standard PMT (amortization) formula. - * - * @param {number} principal - Loan principal - * @param {number} monthlyRate - Monthly interest rate (decimal) - * @param {number} totalMonths - Total number of payments - * @returns {number} Monthly payment - */ -function calculatePMT(principal, monthlyRate, totalMonths) { - if (principal <= 0) return 0; - if (monthlyRate <= 0) return principal / totalMonths; - if (totalMonths <= 0) return 0; - - const factor = Math.pow(1 + monthlyRate, totalMonths); - return principal * (monthlyRate * factor) / (factor - 1); -} +// ─── Comparison builder ────────────────────────────────────────────────────── /** - * Build track-by-track comparisons between the bank offer and BOI averages. + * Build a comparison object between the bank offer and the user's portfolio. * - * Since OCR typically extracts a single blended rate, we compare it against - * each track type in the portfolio and the BOI averages. - * - * @param {object} offer - The analyzed offer - * @param {object} portfolio - The selected portfolio - * @param {object} currentRates - Current BOI averages - * @returns {Array} Track comparison entries + * @param {object} offer - Offer document data. + * @param {object|null} portfolio - User portfolio data. + * @returns {object} Comparison summary. */ -function buildTrackComparisons(offer, portfolio, currentRates) { - const comparisons = []; - const offerRate = offer.extractedData?.rate; - - for (const track of portfolio.tracks) { - const boiRate = currentRates[track.type] || null; - const portfolioRate = track.rate; - - const entry = { - trackType: track.type, - trackName: TRACK_LABELS_HE[track.type] || track.type, - trackNameEn: TRACK_LABELS_EN[track.type] || track.type, - percentage: track.percentage, - portfolioRate, - boiAverage: boiRate, - rateDisplay: track.rateDisplay || `${portfolioRate}%`, - }; - - // Compare portfolio rate to BOI average - if (boiRate != null && portfolioRate != null) { - entry.vsBoi = Math.round((portfolioRate - boiRate) * 100) / 100; - entry.vsBoiLabel = entry.vsBoi > 0 ? 'above_average' : entry.vsBoi < 0 ? 'below_average' : 'at_average'; - } - - // If we have the bank offer rate, compare it too - if (offerRate != null && portfolioRate != null) { - entry.bankOfferRate = offerRate; - entry.vsBank = Math.round((offerRate - portfolioRate) * 100) / 100; - entry.vsBankLabel = entry.vsBank > 0 ? 'bank_higher' : entry.vsBank < 0 ? 'bank_lower' : 'equal'; - } - - comparisons.push(entry); +function buildComparison(offer, portfolio) { + const offerAnalysis = offer.analysis || {}; + const offerTerms = offerAnalysis.terms || {}; + + // Extract bank offer rate (weighted average or first track rate) + const bankRate = + typeof offerTerms.interestRate === 'number' + ? offerTerms.interestRate + : typeof offerTerms.averageRate === 'number' + ? offerTerms.averageRate + : null; + + // Extract portfolio model rate + const portfolioRate = + portfolio && typeof portfolio.averageRate === 'number' + ? portfolio.averageRate + : portfolio && Array.isArray(portfolio.tracks) && portfolio.tracks.length > 0 + ? portfolio.tracks.reduce((sum, t) => sum + (t.rate || 0), 0) / portfolio.tracks.length + : null; + + const loanAmount = + typeof offerTerms.loanAmount === 'number' + ? offerTerms.loanAmount + : typeof offer.loanAmount === 'number' + ? offer.loanAmount + : 0; + + const termYears = + typeof offerTerms.termYears === 'number' + ? offerTerms.termYears + : typeof offer.termYears === 'number' + ? offer.termYears + : 30; + + // Calculate monthly payment delta + let rateDelta = null; + let monthlySaving = null; + let totalSaving = null; + + if (bankRate !== null && portfolioRate !== null) { + rateDelta = parseFloat((bankRate - portfolioRate).toFixed(4)); + + // Simplified monthly payment calculation (annuity formula) + const monthlyBankRate = bankRate / 100 / 12; + const monthlyPortfolioRate = portfolioRate / 100 / 12; + const n = termYears * 12; + + const bankMonthly = + monthlyBankRate > 0 + ? (loanAmount * monthlyBankRate * Math.pow(1 + monthlyBankRate, n)) / + (Math.pow(1 + monthlyBankRate, n) - 1) + : loanAmount / n; + + const portfolioMonthly = + monthlyPortfolioRate > 0 + ? (loanAmount * monthlyPortfolioRate * Math.pow(1 + monthlyPortfolioRate, n)) / + (Math.pow(1 + monthlyPortfolioRate, n) - 1) + : loanAmount / n; + + monthlySaving = Math.round(bankMonthly - portfolioMonthly); + totalSaving = Math.round(monthlySaving * n); } - return comparisons; -} - -// ── AI-Powered Report Generation ────────────────────────────────────────────── - -/** - * Generate the enhanced report sections using OpenAI GPT-4o. - * - * Produces: - * - Mortgage tricks (strategic suggestions) - * - Negotiation script (Hebrew, word-for-word) - * - Strategic insights (explanations) - * - Summary (Hebrew + English) - * - * @param {object} offer - The analyzed offer - * @param {object} portfolio - The selected portfolio - * @param {object} comparison - The comparison data - * @param {object} currentRates - Current BOI averages - * @returns {Promise} AI-generated report sections - */ -async function generateAIReport(offer, portfolio, comparison, currentRates) { - if (!openai) { - throw new Error('OpenAI client not initialized (OPENAI_API_KEY not set)'); - } + // Build track-level comparison + const bankTracks = Array.isArray(offerTerms.tracks) ? offerTerms.tracks : []; + const portfolioTracks = portfolio && Array.isArray(portfolio.tracks) ? portfolio.tracks : []; - const extracted = offer.extractedData || {}; - const bankName = extracted.bank || 'הבנק'; - const offerRate = extracted.rate; - const offerAmount = extracted.amount; - const offerTerm = extracted.term; - - // Build portfolio tracks description - const tracksDesc = portfolio.tracks.map((t) => { - const heLabel = TRACK_LABELS_HE[t.type] || t.type; - return `${heLabel}: ${t.percentage}% at ${t.rate}% (${t.rateDisplay || t.rate + '%'})`; - }).join('\n'); - - // Build comparison summary - const compSummary = comparison.rateDifference != null - ? `Bank offer rate: ${offerRate}%. Optimized model weighted rate: ${comparison.optimizedModel.weightedRate}%. Difference: ${comparison.rateDifference > 0 ? '+' : ''}${comparison.rateDifference}%.` - : 'Bank offer rate not fully extracted from OCR.'; - - const savingsSummary = comparison.potentialTotalSavings != null - ? `Potential total savings: ₪${comparison.potentialTotalSavings.toLocaleString()}. Monthly savings: ₪${comparison.potentialMonthlySavings.toLocaleString()}.` - : 'Savings calculation not available due to incomplete data.'; - - const prompt = `You are an expert Israeli mortgage consultant generating a professional analysis report in Hebrew. - -Context: -- Bank: ${bankName} -- Bank Offer: Rate ${offerRate != null ? offerRate + '%' : 'unknown'}, Amount ₪${offerAmount != null ? offerAmount.toLocaleString() : 'unknown'}, Term ${offerTerm != null ? offerTerm + ' years' : 'unknown'} -- Optimized Portfolio ("${portfolio.nameHe || portfolio.name}"): -${tracksDesc} - Monthly Repayment: ₪${portfolio.monthlyRepayment.toLocaleString()} - Total Cost: ₪${portfolio.totalCost.toLocaleString()} - Total Interest: ₪${portfolio.totalInterest.toLocaleString()} -- Comparison: ${compSummary} -- Savings: ${savingsSummary} -- Current BOI Averages: Fixed ${currentRates.fixed}%, CPI ${currentRates.cpi}%, Prime ${currentRates.prime}%, Variable ${currentRates.variable}% - -Generate a comprehensive report with these sections: - -1. **tricks** (Array of 2-4 mortgage tricks/strategies): - Each trick should have: - - nameHe: Hebrew name (e.g., "מסלול פיתיון") - - nameEn: English name (e.g., "Enticement Track") - - descriptionHe: Hebrew explanation (2-3 sentences) - - descriptionEn: English explanation (2-3 sentences) - - potentialSavings: estimated savings in ₪ (number or null) - - riskLevel: "low", "medium", or "high" - - applicability: "high", "medium", or "low" (how relevant to this specific case) - - MUST include the "Enticement Track" (מסלול פיתיון) strategy: taking a high-interest track to lower the rates on other tracks, then refinancing that track later. - -2. **negotiationScript** (String): A complete, word-for-word Hebrew script for the bank meeting. Must: - - Start with a greeting and introduction - - Reference specific rates from the comparison - - Mention BOI averages as leverage - - Include specific asks (rate reductions per track) - - Be polite but firm - - Be 150-300 words in Hebrew - -3. **insights** (Array of 2-4 strategic insights): - Each insight should have: - - titleHe: Hebrew title - - titleEn: English title - - bodyHe: Hebrew explanation (2-3 sentences) - - bodyEn: English explanation (2-3 sentences) - - icon: suggested icon name (e.g., "shield", "trending-down", "calendar", "target") - -4. **summary**: English summary (2-3 sentences) -5. **summaryHe**: Hebrew summary (2-3 sentences) - -Respond ONLY with valid JSON (no markdown, no explanation): -{ - "tricks": [...], - "negotiationScript": "...", - "insights": [...], - "summary": "...", - "summaryHe": "..." -}`; - - const response = await openai.chat.completions.create({ - model: 'gpt-4o-mini', - messages: [ - { - role: 'system', - content: 'You are an expert Israeli mortgage consultant. You generate professional, actionable reports in Hebrew and English. All financial advice must be practical and specific to the user\'s situation. Respond only with valid JSON.', - }, - { role: 'user', content: prompt }, - ], - max_tokens: 3000, - temperature: 0.4, - response_format: { type: 'json_object' }, + const trackComparison = bankTracks.map((bankTrack) => { + const portfolioTrack = portfolioTracks.find( + (pt) => pt.type === bankTrack.type || pt.name === bankTrack.name + ); + return { + name: bankTrack.name || bankTrack.type || 'Unknown', + bankRate: bankTrack.rate || null, + portfolioRate: portfolioTrack ? portfolioTrack.rate || null : null, + delta: + bankTrack.rate != null && portfolioTrack && portfolioTrack.rate != null + ? parseFloat((bankTrack.rate - portfolioTrack.rate).toFixed(4)) + : null, + }; }); - const content = response.choices[0].message.content; - const parsed = JSON.parse(content); - - // Validate the response structure - if (!parsed.tricks || !Array.isArray(parsed.tricks)) { - throw new Error('AI response missing tricks array'); - } - if (!parsed.negotiationScript || typeof parsed.negotiationScript !== 'string') { - throw new Error('AI response missing negotiationScript'); - } - return { - tricks: parsed.tricks.map(sanitizeTrick), - negotiationScript: parsed.negotiationScript, - insights: Array.isArray(parsed.insights) ? parsed.insights.map(sanitizeInsight) : [], - summary: parsed.summary || '', - summaryHe: parsed.summaryHe || '', + rateDelta, + monthlySaving, + totalSaving, + loanAmount, + termYears, + bankRate, + portfolioRate, + trackComparison, }; } -// ── Rule-Based Report Generation (Fallback) ─────────────────────────────────── +// ─── Fallback rule-based report ────────────────────────────────────────────── /** - * Generate a rule-based report when AI is unavailable. - * - * Produces deterministic tricks, a template negotiation script, - * and basic insights based on the comparison data. + * Generate a rule-based enhanced report when AI is unavailable. * - * @param {object} offer - The analyzed offer - * @param {object} portfolio - The selected portfolio - * @param {object} comparison - The comparison data - * @param {object} currentRates - Current BOI averages - * @returns {object} Rule-based report sections + * @param {object} offer + * @param {object|null} portfolio + * @param {object} comparison + * @returns {object} Enhanced report. */ -function generateRuleBasedReport(offer, portfolio, comparison, currentRates) { - const extracted = offer.extractedData || {}; - const bankName = extracted.bank || 'הבנק'; - const offerRate = extracted.rate; - const rateDiff = comparison.rateDifference; - const savings = comparison.potentialTotalSavings; - - // ── Tricks ────────────────────────────────────────────────────────────────── - const tricks = []; - - // Trick 1: Enticement Track (always included per spec) - tricks.push({ - nameHe: 'מסלול פיתיון', - nameEn: 'Enticement Track', - descriptionHe: - 'קחו מסלול אחד בריבית גבוהה יותר (למשל פריים) כדי להוריד את הריבית במסלולים האחרים. ' + - 'לאחר שנה-שנתיים, בצעו מיחזור של המסלול היקר בלבד. ' + - 'הבנקים מוכנים להוריד ריבית במסלולים אחרים כשהם מרוויחים יותר במסלול אחד.', - descriptionEn: - 'Accept a higher rate on one track (e.g., prime) to negotiate lower rates on other tracks. ' + - 'After 1-2 years, refinance only the expensive track. ' + - 'Banks are willing to lower rates on other tracks when they profit more on one.', - potentialSavings: savings != null ? Math.round(savings * 0.15) : null, - riskLevel: 'medium', - applicability: 'high', - }); - - // Trick 2: Track splitting - if (portfolio.tracks.length >= 2) { - tricks.push({ +function generateFallbackReport(offer, portfolio, comparison) { + const tricks = [ + { + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + descriptionHe: + 'קחו מסלול בריבית גבוהה כדי להוריד את הריבית במסלולים האחרים, ואז מחזרו אותו לאחר שנה-שנתיים.', + descriptionEn: + 'Take a high-interest track to lower rates on other tracks, then refinance it after 1-2 years.', + applicability: 'high', + riskLevel: 'medium', + potentialSavings: comparison.totalSaving ? Math.round(comparison.totalSaving * 0.15) : null, + }, + { nameHe: 'פיצול מסלולים', nameEn: 'Track Splitting', descriptionHe: - 'בקשו לפצל את המשכנתא ליותר מסלולים ממה שהבנק מציע. ' + - 'פיצול מאפשר גמישות רבה יותר במיחזור עתידי ומפחית סיכון ריכוז.', + 'פצלו את ההלוואה למספר מסלולים כדי לפזר סיכונים ולנצל יתרונות של כל סוג ריבית.', descriptionEn: - 'Request splitting the mortgage into more tracks than the bank offers. ' + - 'Splitting provides more flexibility for future refinancing and reduces concentration risk.', - potentialSavings: null, + 'Split the loan across multiple tracks to diversify risk and leverage benefits of each rate type.', + applicability: 'high', riskLevel: 'low', - applicability: 'medium', - }); - } - - // Trick 3: Rate matching with BOI data - if (rateDiff != null && rateDiff > 0) { - tricks.push({ - nameHe: 'התאמת ריבית לנתוני בנק ישראל', - nameEn: 'BOI Rate Matching', + potentialSavings: null, + }, + { + nameHe: 'מיחזור מוקדם', + nameEn: 'Early Refinancing', descriptionHe: - `הריבית שהוצעה לכם (${offerRate}%) גבוהה מהממוצע בבנק ישראל. ` + - `הציגו את נתוני בנק ישראל (קל"צ: ${currentRates.fixed}%, פריים: ${currentRates.prime}%) ` + - 'ובקשו התאמה לממוצע השוק.', + 'תכננו מיחזור לאחר 3-5 שנים אם הריביות ירדו, תוך בדיקת עמלות פירעון מוקדם.', descriptionEn: - `Your offered rate (${offerRate}%) is above the Bank of Israel average. ` + - `Present BOI data (Fixed: ${currentRates.fixed}%, Prime: ${currentRates.prime}%) ` + - 'and request market-rate matching.', - potentialSavings: savings, + 'Plan refinancing after 3-5 years if rates drop, while checking early repayment penalties.', + applicability: 'medium', riskLevel: 'low', - applicability: 'high', - }); - } + potentialSavings: null, + }, + ]; - // Trick 4: Early prepayment leverage - tricks.push({ - nameHe: 'מינוף פירעון מוקדם', - nameEn: 'Early Prepayment Leverage', - descriptionHe: - 'ציינו בפני הבנק שאתם שוקלים פירעון מוקדם חלקי בעתיד. ' + - 'זה מעודד את הבנק להציע ריבית טובה יותר כדי לשמור אתכם כלקוחות לטווח ארוך.', - descriptionEn: - 'Mention to the bank that you are considering partial early repayment in the future. ' + - 'This encourages the bank to offer better rates to retain you as a long-term customer.', - potentialSavings: null, - riskLevel: 'low', - applicability: 'medium', - }); + const bankName = offer.bankName || offer.analysis?.bankName || 'הבנק'; + const rateDeltaStr = + comparison.rateDelta != null + ? `${comparison.rateDelta > 0 ? '+' : ''}${comparison.rateDelta.toFixed(2)}%` + : 'גבוהה מהממוצע'; - // ── Negotiation Script ────────────────────────────────────────────────────── - const rateStr = offerRate != null ? `${offerRate}%` : 'הריבית שהוצעה'; - const targetRate = comparison.optimizedModel.weightedRate != null - ? `${comparison.optimizedModel.weightedRate}%` - : 'ריבית תחרותית יותר'; - - const negotiationScript = - `שלום, שמי [שם]. אני מעוניין/ת במשכנתא ועשיתי מחקר מקיף לפני הפגישה.\n\n` + - `בדקתי את נתוני בנק ישראל העדכניים וראיתי שהממוצע לריבית קבועה לא צמודה עומד על ${currentRates.fixed}%, ` + - `ולפריים על ${currentRates.prime}%.\n\n` + - `ההצעה שקיבלתי מ-${bankName} עומדת על ${rateStr}, ` + - `שזה ${rateDiff != null && rateDiff > 0 ? `${rateDiff}% מעל הממוצע בשוק` : 'קרוב לממוצע בשוק'}.\n\n` + - `על בסיס הניתוח שלי, אני מבקש/ת להגיע לריבית משוקללת של ${targetRate}. ` + - `${savings != null ? `הפער הנוכחי מייצג חיסכון פוטנציאלי של כ-₪${savings.toLocaleString()} לאורך חיי ההלוואה.` : ''}\n\n` + - `אני פתוח/ה לדון בתמהיל המסלולים – למשל, אני מוכן/ה לשקול ריבית מעט גבוהה יותר במסלול אחד ` + - `אם זה יאפשר הורדה משמעותית במסלולים האחרים.\n\n` + - `קיבלתי הצעות גם מבנקים אחרים, ואשמח לתת ל-${bankName} את ההזדמנות להציע את התנאים הטובים ביותר.\n\n` + - `תודה רבה.`; - - // ── Insights ──────────────────────────────────────────────────────────────── - const insights = []; - - // Insight 1: Rate comparison - if (rateDiff != null) { - insights.push({ - titleHe: rateDiff > 0 ? 'הריבית שלכם גבוהה מהממוצע' : 'הריבית שלכם תחרותית', - titleEn: rateDiff > 0 ? 'Your Rate is Above Average' : 'Your Rate is Competitive', - bodyHe: rateDiff > 0 - ? `הריבית שהוצעה לכם גבוהה ב-${rateDiff}% מהמודל האופטימלי שלנו. יש מקום למשא ומתן משמעותי.` - : `הריבית שהוצעה לכם קרובה למודל האופטימלי. עדיין ניתן לנסות לשפר בנקודות ספציפיות.`, - bodyEn: rateDiff > 0 - ? `Your offered rate is ${rateDiff}% above our optimized model. There is significant room for negotiation.` - : `Your offered rate is close to the optimized model. You can still try to improve on specific points.`, - icon: rateDiff > 0 ? 'trending-down' : 'check-circle', - }); - } + const negotiationScript = `שלום, שמי [שם]. אני מעוניין/ת במשכנתא בסך ${( + comparison.loanAmount || 0 + ).toLocaleString('he-IL')} ₪ ל-${comparison.termYears || 30} שנים. - // Insight 2: Portfolio strategy - insights.push({ - titleHe: 'אסטרטגיית תמהיל', - titleEn: 'Portfolio Strategy', - bodyHe: - `התיק "${portfolio.nameHe || portfolio.name}" מבוסס על תמהיל של ${portfolio.tracks.length} מסלולים ` + - `לתקופה של ${portfolio.termYears} שנים. ` + - `תמהיל זה מאזן בין עלות כוללת להחזר חודשי נוח.`, - bodyEn: - `The "${portfolio.name}" portfolio is based on a mix of ${portfolio.tracks.length} tracks ` + - `over ${portfolio.termYears} years. ` + - `This mix balances total cost with comfortable monthly payments.`, - icon: 'target', - }); +בדקתי את נתוני בנק ישראל העדכניים וראיתי שהממוצע לריבית קבועה לא צמודה עומד על ${( + comparison.portfolioRate || 0 + ).toFixed(2)}%. - // Insight 3: Market timing - insights.push({ - titleHe: 'תזמון שוק', - titleEn: 'Market Timing', - bodyHe: - `ריביות בנק ישראל הנוכחיות: קל"צ ${currentRates.fixed}%, צמוד ${currentRates.cpi}%, פריים ${currentRates.prime}%. ` + - 'השתמשו בנתונים אלה כמנוף במשא ומתן.', - bodyEn: - `Current BOI rates: Fixed ${currentRates.fixed}%, CPI ${currentRates.cpi}%, Prime ${currentRates.prime}%. ` + - 'Use these figures as leverage in negotiations.', - icon: 'calendar', - }); +הצעת ${bankName} שקיבלתי עומדת על ${rateDeltaStr} מעל הממוצע. אני מבקש/ת שתתאימו את ההצעה לממוצע השוק. - // ── Summary ───────────────────────────────────────────────────────────────── - const summaryHe = rateDiff != null && rateDiff > 0 - ? `ההצעה מ-${bankName} גבוהה ב-${rateDiff}% מהמודל האופטימלי. ${savings != null ? `חיסכון פוטנציאלי: ₪${savings.toLocaleString()}.` : ''} מומלץ לנהל משא ומתן.` - : `ההצעה מ-${bankName} קרובה למודל האופטימלי. עדיין ניתן לשפר בנקודות ספציפיות.`; +אם תוכלו להציע לי ריבית תחרותית יותר, אני מוכן/ה לסגור את העסקה עוד היום. - const summary = rateDiff != null && rateDiff > 0 - ? `The offer from ${bankName} is ${rateDiff}% above the optimized model. ${savings != null ? `Potential savings: ₪${savings.toLocaleString()}.` : ''} Negotiation recommended.` - : `The offer from ${bankName} is close to the optimized model. Minor improvements may still be possible.`; +תודה רבה.`; + + const insights = [ + { + titleHe: 'ניתוח הריבית', + titleEn: 'Rate Analysis', + bodyHe: `הריבית המוצעת ${rateDeltaStr} מהממוצע בשוק. יש מקום למשא ומתן.`, + bodyEn: `The offered rate is ${rateDeltaStr} from market average. There is room for negotiation.`, + icon: 'trending-down', + }, + { + titleHe: 'המלצת מסלול', + titleEn: 'Track Recommendation', + bodyHe: 'שקלו שילוב של מסלול קל"צ ופריים לאיזון בין יציבות לגמישות.', + bodyEn: 'Consider combining fixed and prime tracks for a balance of stability and flexibility.', + icon: 'target', + }, + { + titleHe: 'תכנון עתידי', + titleEn: 'Future Planning', + bodyHe: 'תכננו מיחזור בעוד 5 שנים בהתאם לשינויי הריבית בשוק.', + bodyEn: 'Plan refinancing in 5 years based on market rate changes.', + icon: 'calendar', + }, + ]; return { - tricks: tricks.slice(0, 4), + tricks, negotiationScript, insights, - summary, - summaryHe, + comparison, + generatedAt: new Date().toISOString(), + generatedBy: 'rule-based-fallback', + processingTimeMs: 0, }; } -// ── Sanitization Helpers ────────────────────────────────────────────────────── +// ─── AI-powered report generation ──────────────────────────────────────────── /** - * Sanitize an AI-generated trick object to ensure consistent shape. - * - * @param {object} trick - Raw trick from AI - * @returns {object} Sanitized trick + * Build the system prompt for the AI enhanced report. */ -function sanitizeTrick(trick) { - return { - nameHe: String(trick.nameHe || trick.name || ''), - nameEn: String(trick.nameEn || trick.name || ''), - descriptionHe: String(trick.descriptionHe || trick.description || ''), - descriptionEn: String(trick.descriptionEn || trick.description || ''), - potentialSavings: typeof trick.potentialSavings === 'number' ? trick.potentialSavings : null, - riskLevel: ['low', 'medium', 'high'].includes(trick.riskLevel) ? trick.riskLevel : 'medium', - applicability: ['low', 'medium', 'high'].includes(trick.applicability) ? trick.applicability : 'medium', - }; +function buildSystemPrompt() { + return `You are an expert Israeli mortgage consultant (יועץ משכנתאות מומחה). +You analyse bank mortgage offers and provide professional advice in both Hebrew and English. + +You MUST respond with a valid JSON object containing exactly these fields: +{ + "tricks": [ + { + "nameHe": "string (Hebrew name)", + "nameEn": "string (English name)", + "descriptionHe": "string (2-3 sentences in Hebrew)", + "descriptionEn": "string (2-3 sentences in English)", + "applicability": "high|medium|low", + "riskLevel": "low|medium|high", + "potentialSavings": number_or_null + } + ], + "negotiationScript": "string (full Hebrew negotiation script, word-for-word, RTL)", + "insights": [ + { + "titleHe": "string", + "titleEn": "string", + "bodyHe": "string (2-3 sentences in Hebrew)", + "bodyEn": "string (2-3 sentences in English)", + "icon": "trending-down|check-circle|target|calendar|shield|info" + } + ] +} + +Rules: +- tricks: 3-5 actionable mortgage strategies. ALWAYS include 'מסלול פיתיון' (Enticement Track) if applicable. +- negotiationScript: A complete, professional, word-for-word Hebrew script for the bank meeting. Include specific numbers from the offer. +- insights: 3-5 strategic insights explaining the WHY behind recommendations. +- Do NOT include PII. Use [שם] placeholder for the borrower's name. +- All monetary values in ILS (₪). +- Respond ONLY with the JSON object, no markdown.`; } /** - * Sanitize an AI-generated insight object to ensure consistent shape. - * - * @param {object} insight - Raw insight from AI - * @returns {object} Sanitized insight + * Build the user prompt with offer and portfolio context. */ -function sanitizeInsight(insight) { - return { - titleHe: String(insight.titleHe || insight.title || ''), - titleEn: String(insight.titleEn || insight.title || ''), - bodyHe: String(insight.bodyHe || insight.body || ''), - bodyEn: String(insight.bodyEn || insight.body || ''), - icon: String(insight.icon || 'info'), +function buildUserPrompt(offer, portfolio, comparison) { + const offerAnalysis = offer.analysis || {}; + const offerTerms = offerAnalysis.terms || {}; + + // Anonymise: only include financial data, no PII + const context = { + bankName: offer.bankName || offerAnalysis.bankName || 'Unknown Bank', + loanAmount: comparison.loanAmount, + termYears: comparison.termYears, + bankRate: comparison.bankRate, + portfolioRate: comparison.portfolioRate, + rateDelta: comparison.rateDelta, + monthlySaving: comparison.monthlySaving, + totalSaving: comparison.totalSaving, + tracks: offerTerms.tracks || [], + portfolioTracks: portfolio ? portfolio.tracks || [] : [], + trackComparison: comparison.trackComparison, + offerStatus: offer.status, }; + + return `Analyse this Israeli mortgage offer and provide expert advice: + +${JSON.stringify(context, null, 2)} + +Generate: +1. 3-5 mortgage tricks/strategies specific to this offer +2. A complete Hebrew negotiation script using the actual numbers above +3. 3-5 strategic insights explaining the recommendations + +Respond with the JSON object as specified.`; } -// ── Validation ──────────────────────────────────────────────────────────────── +// ─── Main export ───────────────────────────────────────────────────────────── /** - * Validate the portfolio object structure. + * Generate an enhanced AI-powered mortgage analysis report. + * + * Steps: + * 1. Build comparison between offer and portfolio. + * 2. Call AI (GPT-4o-mini) for tricks, script, and insights. + * 3. Fall back to rule-based report if AI fails. + * 4. Store the result in `offer.analysis.enhanced` via offerService. * - * @param {object} portfolio - Portfolio to validate - * @throws {Error} If portfolio is invalid + * @param {string} offerId - Firestore document ID of the offer. + * @param {string} userId - UID of the authenticated user. + * @param {object} offer - Full offer document data. + * @param {object|null} portfolio - User's portfolio data. + * @returns {Promise} The generated enhanced report. */ -function validatePortfolio(portfolio) { - if (!portfolio || typeof portfolio !== 'object') { - const err = new Error('Portfolio data is required'); - err.statusCode = 400; - throw err; - } +async function generateEnhancedReport(offerId, userId, offer, portfolio) { + const startTime = Date.now(); + logger.info('Generating enhanced report', { offerId, userId }); - if (!portfolio.id || typeof portfolio.id !== 'string') { - const err = new Error('Portfolio must have a valid id'); - err.statusCode = 400; - throw err; - } + // Build comparison data + const comparison = buildComparison(offer, portfolio); - if (!Array.isArray(portfolio.tracks) || portfolio.tracks.length === 0) { - const err = new Error('Portfolio must have at least one track'); - err.statusCode = 400; - throw err; - } + let enhancedReport; + let generatedBy = 'ai'; - if (typeof portfolio.termYears !== 'number' || portfolio.termYears <= 0) { - const err = new Error('Portfolio must have a valid termYears'); - err.statusCode = 400; - throw err; - } + // Attempt AI generation + try { + if (!process.env.OPENAI_API_KEY) { + throw new Error('OpenAI API key not configured'); + } - if (typeof portfolio.monthlyRepayment !== 'number' || portfolio.monthlyRepayment <= 0) { - const err = new Error('Portfolio must have a valid monthlyRepayment'); - err.statusCode = 400; - throw err; - } + const systemPrompt = buildSystemPrompt(); + const userPrompt = buildUserPrompt(offer, portfolio, comparison); - if (typeof portfolio.totalCost !== 'number' || portfolio.totalCost <= 0) { - const err = new Error('Portfolio must have a valid totalCost'); - err.statusCode = 400; - throw err; - } + const aiResponse = await callGPT(systemPrompt, userPrompt, { + temperature: 0.4, + maxTokens: 3000, + }); - if (typeof portfolio.totalInterest !== 'number' || portfolio.totalInterest < 0) { - const err = new Error('Portfolio must have a valid totalInterest'); - err.statusCode = 400; - throw err; - } + // Sanitise AI output + const tricks = Array.isArray(aiResponse.tricks) + ? aiResponse.tricks.map(sanitizeTrick).filter(Boolean) + : []; - // Validate each track - for (const track of portfolio.tracks) { - if (!track.type || typeof track.type !== 'string') { - const err = new Error('Each track must have a valid type'); - err.statusCode = 400; - throw err; - } - if (typeof track.percentage !== 'number' || track.percentage <= 0 || track.percentage > 100) { - const err = new Error('Each track must have a valid percentage (1-100)'); - err.statusCode = 400; - throw err; - } - if (typeof track.rate !== 'number' || track.rate < 0) { - const err = new Error('Each track must have a valid rate'); - err.statusCode = 400; - throw err; + const insights = Array.isArray(aiResponse.insights) + ? aiResponse.insights.map(sanitizeInsight).filter(Boolean) + : []; + + const negotiationScript = + typeof aiResponse.negotiationScript === 'string' + ? aiResponse.negotiationScript.slice(0, 5000) + : ''; + + if (tricks.length === 0 || !negotiationScript) { + throw new Error('AI response missing required fields'); } + + enhancedReport = { + tricks, + negotiationScript, + insights, + comparison, + generatedAt: new Date().toISOString(), + generatedBy, + processingTimeMs: Date.now() - startTime, + }; + + logger.info('AI enhanced report generated successfully', { + offerId, + processingTimeMs: enhancedReport.processingTimeMs, + }); + } catch (aiError) { + logger.warn('AI generation failed, using rule-based fallback', { + offerId, + error: aiError.message, + }); + + enhancedReport = generateFallbackReport(offer, portfolio, comparison); + enhancedReport.processingTimeMs = Date.now() - startTime; + generatedBy = 'rule-based-fallback'; + enhancedReport.generatedBy = generatedBy; } - // Validate percentages sum to ~100 - const totalPct = portfolio.tracks.reduce((sum, t) => sum + t.percentage, 0); - if (totalPct < 98 || totalPct > 102) { - const err = new Error(`Track percentages must sum to 100% (got ${totalPct}%)`); - err.statusCode = 400; - throw err; + // Persist to Firestore + try { + await updateEnhancedAnalysis(offerId, enhancedReport); + } catch (storeError) { + logger.error('Failed to store enhanced report', { + offerId, + error: storeError.message, + }); + // Don't throw — still return the report to the client } -} -// ── Exports ─────────────────────────────────────────────────────────────────── + return enhancedReport; +} module.exports = { - // Main entry point generateEnhancedReport, - - // Internal helpers (exported for testing) buildComparison, - calculateWeightedRate, - estimateSavings, - calculatePMT, - buildTrackComparisons, - generateAIReport, - generateRuleBasedReport, sanitizeTrick, sanitizeInsight, - validatePortfolio, - - // Constants - TRACK_LABELS_HE, - TRACK_LABELS_EN, + generateFallbackReport, }; diff --git a/src/utils/errors.js b/src/utils/errors.js index 2a0c3bb..5db9d46 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -1,211 +1,67 @@ +'use strict'; + /** - * Custom error classes for the Morty backend. - * - * Provides a hierarchy of operational errors that map to HTTP status codes, - * plus utility helpers (asyncHandler) used throughout controllers. - * - * NOTE: Mongoose/MongoDB-specific error handling has been removed as part of - * the Firestore migration. Firestore errors are handled in errorHandler.js. + * Base application error class. */ - -// ── Base error ──────────────────────────────────────────────────────────────── - class AppError extends Error { /** - * @param {string} message - Human-readable error message - * @param {number} statusCode - HTTP status code - * @param {string} errorCode - Machine-readable error code - * @param {*} details - Optional extra details (validation errors, etc.) + * @param {string} message - Human-readable error message. + * @param {number} statusCode - HTTP status code. + * @param {string} [code] - Machine-readable error code. */ - constructor(message, statusCode = 500, errorCode = 'INTERNAL_SERVER_ERROR', details = null) { + constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') { super(message); this.name = this.constructor.name; this.statusCode = statusCode; - this.errorCode = errorCode; - this.details = details; + this.code = code; this.isOperational = true; - this.timestamp = new Date().toISOString(); Error.captureStackTrace(this, this.constructor); } - - toJSON() { - return { - error: { - code: this.errorCode, - message: this.message, - ...(this.details !== null && { details: this.details }), - timestamp: this.timestamp, - }, - }; - } -} - -// ── Derived error classes ───────────────────────────────────────────────────── - -class ValidationError extends AppError { - constructor(message = 'Validation failed', details = null) { - super(message, 400, 'VALIDATION_ERROR', details); - } -} - -class AuthenticationError extends AppError { - constructor(message = 'Authentication required') { - super(message, 401, 'AUTHENTICATION_ERROR'); - } -} - -class AuthorizationError extends AppError { - constructor(message = 'Access denied') { - super(message, 403, 'AUTHORIZATION_ERROR'); - } } class NotFoundError extends AppError { - constructor(resource = 'Resource') { - super(`${resource} not found`, 404, 'NOT_FOUND'); - } -} - -class ConflictError extends AppError { - constructor(message = 'Resource already exists') { - super(message, 409, 'CONFLICT_ERROR'); + constructor(message = 'Resource not found') { + super(message, 404, 'NOT_FOUND'); } } class UnauthorizedError extends AppError { constructor(message = 'Unauthorized') { - super(message, 401, 'AUTHENTICATION_ERROR'); + super(message, 401, 'UNAUTHORIZED'); } } -class PayloadTooLargeError extends AppError { - constructor(message = 'Payload too large') { - super(message, 413, 'PAYLOAD_TOO_LARGE'); +class ForbiddenError extends AppError { + constructor(message = 'Forbidden') { + super(message, 403, 'FORBIDDEN'); } } -class InternalServerError extends AppError { - constructor(message = 'Internal server error') { - super(message, 500, 'INTERNAL_SERVER_ERROR'); - this.isOperational = false; +class ValidationError extends AppError { + constructor(message = 'Validation failed', details = null) { + super(message, 400, 'VALIDATION_ERROR'); + this.details = details; } } -/** - * 415 Unsupported Media Type – used when an uploaded file has a disallowed - * MIME type or extension. - */ -class UnsupportedMediaTypeError extends AppError { - constructor(message = 'Unsupported media type') { - super(message, 415, 'UNSUPPORTED_MEDIA_TYPE'); +class ConflictError extends AppError { + constructor(message = 'Conflict') { + super(message, 409, 'CONFLICT'); } } -/** - * 429 Too Many Requests – used by rate-limiting middleware. - */ -class RateLimitError extends AppError { - constructor(message = 'Too many requests, please try again later') { - super(message, 429, 'RATE_LIMIT_EXCEEDED'); +class ServiceUnavailableError extends AppError { + constructor(message = 'Service temporarily unavailable') { + super(message, 503, 'SERVICE_UNAVAILABLE'); } } -// ── JWT error handler ───────────────────────────────────────────────────────── - -/** - * Convert a jsonwebtoken error into an AuthenticationError. - * - * @param {Error} err - * @returns {AuthenticationError|null} - */ -const handleJWTError = (err) => { - if (err.name === 'JsonWebTokenError') { - return new AuthenticationError('Invalid token'); - } - if (err.name === 'TokenExpiredError') { - return new AuthenticationError('Token has expired'); - } - if (err.name === 'NotBeforeError') { - return new AuthenticationError('Token not yet valid'); - } - return null; -}; - -/** - * Convert a Firestore / Google Cloud error into an AppError where possible. - * - * Firestore errors carry a `code` property (gRPC status code string) and - * a `details` string. We map the most common ones to HTTP-friendly errors. - * - * @param {Error} err - * @returns {AppError|null} - */ -const handleFirestoreError = (err) => { - if (!err || !err.code) return null; - - // gRPC status codes used by the Firestore Admin SDK - switch (err.code) { - case 5: // NOT_FOUND - case 'NOT_FOUND': - return new NotFoundError('Firestore document'); - - case 6: // ALREADY_EXISTS - case 'ALREADY_EXISTS': - return new ConflictError('Document already exists'); - - case 7: // PERMISSION_DENIED - case 'PERMISSION_DENIED': - return new AuthorizationError('Firestore permission denied'); - - case 16: // UNAUTHENTICATED - case 'UNAUTHENTICATED': - return new AuthenticationError('Firestore authentication failed'); - - case 8: // RESOURCE_EXHAUSTED (quota) - case 'RESOURCE_EXHAUSTED': - return new RateLimitError('Firestore quota exceeded'); - - case 4: // DEADLINE_EXCEEDED - case 'DEADLINE_EXCEEDED': - return new InternalServerError('Firestore request timed out'); - - case 14: // UNAVAILABLE - case 'UNAVAILABLE': - return new InternalServerError('Firestore service temporarily unavailable'); - - default: - return null; - } -}; - -// ── Async handler ───────────────────────────────────────────────────────────── - -/** - * Wrap an async route handler so that any rejected promise is forwarded - * to Express's next(err) error handler. - * - * @param {Function} fn - Async route handler - * @returns {Function} - */ -const asyncHandler = (fn) => (req, res, next) => { - Promise.resolve(fn(req, res, next)).catch(next); -}; - -// ── Exports ─────────────────────────────────────────────────────────────────── - module.exports = { AppError, - ValidationError, - AuthenticationError, - AuthorizationError, NotFoundError, - ConflictError, UnauthorizedError, - PayloadTooLargeError, - InternalServerError, - UnsupportedMediaTypeError, - RateLimitError, - handleJWTError, - handleFirestoreError, - asyncHandler, + ForbiddenError, + ValidationError, + ConflictError, + ServiceUnavailableError, }; diff --git a/src/utils/jwt.js b/src/utils/jwt.js index 8f84ab2..44451d4 100644 --- a/src/utils/jwt.js +++ b/src/utils/jwt.js @@ -1,112 +1,35 @@ -/** - * JWT utility functions. - * - * Provides helpers for generating and verifying access and refresh tokens. - * - * Token lifetimes (configurable via environment variables): - * - Access token: JWT_EXPIRES_IN (default: '15m') - * - Refresh token: JWT_REFRESH_EXPIRES_IN (default: '7d') - * - * Secrets (required in production): - * - JWT_SECRET – signs/verifies access tokens - * - JWT_REFRESH_SECRET – signs/verifies refresh tokens - * Falls back to JWT_SECRET + '_refresh' when not set (dev only). - */ - 'use strict'; const jwt = require('jsonwebtoken'); +const { UnauthorizedError } = require('./errors'); -const ACCESS_TOKEN_SECRET = process.env.JWT_SECRET; -const REFRESH_TOKEN_SECRET = - process.env.JWT_REFRESH_SECRET || (process.env.JWT_SECRET && process.env.JWT_SECRET + '_refresh'); - -/** - * Default access token lifetime: 15 minutes. - * - * Per architecture spec: short-lived access tokens reduce the window of - * exposure if a token is leaked. Clients must use the refresh token to - * obtain a new access token after expiry. - * - * Override via JWT_EXPIRES_IN environment variable (e.g. '30m', '1h'). - */ -const ACCESS_TOKEN_EXPIRY = process.env.JWT_EXPIRES_IN || '15m'; - -/** - * Default refresh token lifetime: 7 days. - * - * Override via JWT_REFRESH_EXPIRES_IN environment variable (e.g. '30d'). - */ -const REFRESH_TOKEN_EXPIRY = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; - -/** - * Generate a short-lived access token. - * - * The payload should contain the minimum required claims (e.g. { id }). - * Do NOT include sensitive data (passwords, raw emails, etc.). - * - * @param {Object} payload - Data to encode (e.g. { id: firestoreUserId }) - * @returns {string} Signed JWT access token - * @throws {Error} When JWT_SECRET is not configured - */ -function generateAccessToken(payload) { - if (!ACCESS_TOKEN_SECRET) { - throw new Error('JWT_SECRET environment variable is not set.'); - } - return jwt.sign(payload, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY }); -} - -/** - * Generate a long-lived refresh token. - * - * Refresh tokens are stored in Firestore (one per user) and rotated on - * every use. They are signed with a separate secret so that a compromised - * access token cannot be used to forge a refresh token. - * - * @param {Object} payload - Data to encode (e.g. { id: firestoreUserId }) - * @returns {string} Signed JWT refresh token - * @throws {Error} When JWT_REFRESH_SECRET is not configured - */ -function generateRefreshToken(payload) { - if (!REFRESH_TOKEN_SECRET) { - throw new Error('JWT_REFRESH_SECRET environment variable is not set.'); - } - return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY }); -} +const JWT_SECRET = process.env.JWT_SECRET || 'morty-dev-secret-change-in-production'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; /** - * Verify and decode an access token. - * - * @param {string} token - JWT access token - * @returns {Object} Decoded payload - * @throws {JsonWebTokenError} Token is malformed or signature is invalid - * @throws {TokenExpiredError} Token has expired + * Sign a JWT token. + * @param {object} payload + * @returns {string} */ -function verifyAccessToken(token) { - if (!ACCESS_TOKEN_SECRET) { - throw new Error('JWT_SECRET environment variable is not set.'); - } - return jwt.verify(token, ACCESS_TOKEN_SECRET); +function signToken(payload) { + return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); } /** - * Verify and decode a refresh token. - * - * @param {string} token - JWT refresh token - * @returns {Object} Decoded payload - * @throws {JsonWebTokenError} Token is malformed or signature is invalid - * @throws {TokenExpiredError} Token has expired + * Verify and decode a JWT token. + * @param {string} token + * @returns {object} decoded payload + * @throws {UnauthorizedError} */ -function verifyRefreshToken(token) { - if (!REFRESH_TOKEN_SECRET) { - throw new Error('JWT_REFRESH_SECRET environment variable is not set.'); +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (err) { + if (err.name === 'TokenExpiredError') { + throw new UnauthorizedError('Token has expired'); + } + throw new UnauthorizedError('Invalid token'); } - return jwt.verify(token, REFRESH_TOKEN_SECRET); } -module.exports = { - generateAccessToken, - generateRefreshToken, - verifyAccessToken, - verifyRefreshToken, -}; +module.exports = { signToken, verifyToken }; diff --git a/src/utils/logger.js b/src/utils/logger.js index 691ad8a..6e99d71 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,55 +1,31 @@ -/** - * Winston logger with security event support. - * - * Provides structured logging for the Morty backend. - * The `logSecurity` method is used by security middleware to record - * suspicious or blocked requests. - */ -const { createLogger, format, transports } = require('winston'); +'use strict'; -const winstonLogger = createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: format.combine( - format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - format.errors({ stack: true }), - format.printf(({ timestamp, level, message, stack, ...meta }) => { - const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; - return stack - ? `${timestamp} [${level.toUpperCase()}] ${message}${metaStr}\n${stack}` - : `${timestamp} [${level.toUpperCase()}] ${message}${metaStr}`; - }) +const winston = require('winston'); + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +const logFormat = printf(({ level, message, timestamp: ts, stack, ...meta }) => { + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; + return `${ts} [${level}]: ${stack || message}${metaStr}`; +}); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'), + format: combine( + errors({ stack: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat ), transports: [ - new transports.Console(), - ...(process.env.NODE_ENV === 'production' - ? [ - new transports.File({ filename: 'logs/error.log', level: 'error' }), - new transports.File({ filename: 'logs/combined.log' }), - ] - : []), + new winston.transports.Console({ + format: combine( + colorize(), + errors({ stack: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat + ), + }), ], }); -/** - * Thin wrapper around the Winston logger that adds a `logSecurity` helper. - * All standard Winston methods (info, warn, error, debug) are proxied. - */ -const logger = { - info: (message, meta) => winstonLogger.info(message, meta), - warn: (message, meta) => winstonLogger.warn(message, meta), - error: (message, meta) => winstonLogger.error(message, meta), - debug: (message, meta) => winstonLogger.debug(message, meta), - verbose: (message, meta) => winstonLogger.verbose(message, meta), - - /** - * Log a security-relevant event (CORS block, rate limit, suspicious input, etc.). - * - * @param {string} eventType - Short identifier for the event (e.g. 'CORS_BLOCKED') - * @param {Object} [context] - Additional context (ip, path, userId, …) - */ - logSecurity: (eventType, context = {}) => { - winstonLogger.warn(`[SECURITY] ${eventType}`, context); - }, -}; - module.exports = logger; diff --git a/src/utils/response.js b/src/utils/response.js index bc0f7ad..2bdf109 100644 --- a/src/utils/response.js +++ b/src/utils/response.js @@ -1,108 +1,42 @@ -/** - * API response helpers for consistent JSON response format. - * All API responses follow the same structure for predictability. - */ - -/** - * Send a successful response. - * - * @param {Object} res - Express response object - * @param {*} data - Response data - * @param {string} [message] - Optional success message - * @param {number} [statusCode=200] - HTTP status code - * - * @example - * sendSuccess(res, { user }, 'User created', 201); - */ -const sendSuccess = (res, data = null, message = 'Success', statusCode = 200) => { - const response = { - success: true, - message, - ...(data !== null && { data }), - timestamp: new Date().toISOString(), - }; - - return res.status(statusCode).json(response); -}; +'use strict'; /** - * Send a created (201) response. + * Send a standardised success response. * - * @param {Object} res - Express response object - * @param {*} data - Created resource data - * @param {string} [message='Created successfully'] - Success message + * @param {import('express').Response} res + * @param {*} data - Payload to include under `data`. + * @param {number} [statusCode=200] + * @param {string} [message='Success'] */ -const sendCreated = (res, data, message = 'Created successfully') => { - return sendSuccess(res, data, message, 201); -}; - -/** - * Send a no-content (204) response. - * - * @param {Object} res - Express response object - */ -const sendNoContent = (res) => { - return res.status(204).send(); -}; - -/** - * Send a paginated list response. - * - * @param {Object} res - Express response object - * @param {Array} items - Array of items - * @param {Object} pagination - Pagination metadata - * @param {number} pagination.page - Current page number - * @param {number} pagination.limit - Items per page - * @param {number} pagination.total - Total number of items - * @param {string} [message='Success'] - Success message - */ -const sendPaginated = (res, items, pagination, message = 'Success') => { - const { page, limit, total } = pagination; - const totalPages = Math.ceil(total / limit); - - return res.status(200).json({ +function sendSuccess(res, data, statusCode = 200, message = 'Success') { + return res.status(statusCode).json({ success: true, message, - data: items, - pagination: { - page, - limit, - total, - totalPages, - hasNextPage: page < totalPages, - hasPrevPage: page > 1, - }, - timestamp: new Date().toISOString(), + data, }); -}; +} /** - * Send an error response. + * Send a standardised error response. * - * @param {Object} res - Express response object - * @param {string} message - Error message - * @param {number} [statusCode=500] - HTTP status code - * @param {string} [errorCode='ERROR'] - Machine-readable error code - * @param {*} [details] - Additional error details + * @param {import('express').Response} res + * @param {string} message + * @param {number} [statusCode=500] + * @param {string} [code='INTERNAL_ERROR'] + * @param {*} [details=null] */ -const sendError = (res, message, statusCode = 500, errorCode = 'ERROR', details = null) => { - const response = { +function sendError(res, message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) { + const body = { success: false, error: { - code: errorCode, + code, message, - ...(details && { details }), }, - timestamp: new Date().toISOString(), }; + if (details) { + body.error.details = details; + } + return res.status(statusCode).json(body); +} - return res.status(statusCode).json(response); -}; - -module.exports = { - sendSuccess, - sendCreated, - sendNoContent, - sendPaginated, - sendError, -}; +module.exports = { sendSuccess, sendError }; diff --git a/src/validators/analysisValidator.js b/src/validators/analysisValidator.js index d071831..3e613a6 100644 --- a/src/validators/analysisValidator.js +++ b/src/validators/analysisValidator.js @@ -1,138 +1,29 @@ -/** - * Joi validation schemas for analysis endpoints. - * - * Validates the request body for the enhanced analysis endpoint - * to ensure the portfolio data is well-formed before processing. - */ - 'use strict'; const Joi = require('joi'); +const { validate } = require('../middleware/validate'); /** - * Schema for a single portfolio track. + * Validates that `:offerId` route parameter is a non-empty string. + * Firestore document IDs are alphanumeric strings (up to 1500 bytes). */ -const trackSchema = Joi.object({ - type: Joi.string() - .valid('fixed', 'cpi', 'prime', 'variable') - .required() - .messages({ - 'any.only': 'Track type must be one of: fixed, cpi, prime, variable', - 'any.required': 'Track type is required', - }), - name: Joi.string().max(200).optional(), - nameEn: Joi.string().max(200).optional(), - percentage: Joi.number() - .min(1) - .max(100) - .required() - .messages({ - 'number.min': 'Track percentage must be at least 1%', - 'number.max': 'Track percentage cannot exceed 100%', - 'any.required': 'Track percentage is required', - }), - rate: Joi.number() - .min(0) - .max(30) - .required() - .messages({ - 'number.min': 'Track rate cannot be negative', - 'number.max': 'Track rate cannot exceed 30%', - 'any.required': 'Track rate is required', - }), - rateDisplay: Joi.string().max(50).optional(), - amount: Joi.number().min(0).optional(), - monthlyPayment: Joi.number().min(0).optional(), - totalCost: Joi.number().min(0).optional(), - totalInterest: Joi.number().min(0).optional(), -}).options({ allowUnknown: true }); - -/** - * Schema for the portfolio object in the enhanced analysis request. - */ -const portfolioSchema = Joi.object({ - id: Joi.string() - .trim() - .min(1) - .max(100) - .required() - .messages({ - 'string.empty': 'Portfolio ID cannot be empty', - 'any.required': 'Portfolio ID is required', - }), - type: Joi.string().max(100).optional(), - name: Joi.string() +const offerIdParamSchema = Joi.object({ + offerId: Joi.string() .trim() .min(1) - .max(200) + .max(128) + .pattern(/^[a-zA-Z0-9_-]+$/) .required() .messages({ - 'string.empty': 'Portfolio name cannot be empty', - 'any.required': 'Portfolio name is required', + 'string.empty': 'offerId cannot be empty', + 'string.pattern.base': 'offerId contains invalid characters', + 'any.required': 'offerId is required', }), - nameHe: Joi.string().max(200).optional(), - description: Joi.string().max(1000).optional(), - termYears: Joi.number() - .integer() - .min(1) - .max(40) - .required() - .messages({ - 'number.min': 'Term must be at least 1 year', - 'number.max': 'Term cannot exceed 40 years', - 'any.required': 'Term years is required', - }), - tracks: Joi.array() - .items(trackSchema) - .min(1) - .max(10) - .required() - .messages({ - 'array.min': 'Portfolio must have at least one track', - 'array.max': 'Portfolio cannot have more than 10 tracks', - 'any.required': 'Portfolio tracks are required', - }), - monthlyRepayment: Joi.number() - .min(1) - .required() - .messages({ - 'number.min': 'Monthly repayment must be positive', - 'any.required': 'Monthly repayment is required', - }), - totalCost: Joi.number() - .min(1) - .required() - .messages({ - 'number.min': 'Total cost must be positive', - 'any.required': 'Total cost is required', - }), - totalInterest: Joi.number() - .min(0) - .required() - .messages({ - 'number.min': 'Total interest cannot be negative', - 'any.required': 'Total interest is required', - }), - interestSavings: Joi.number().min(0).optional(), - fitnessScore: Joi.number().min(0).max(100).optional(), - recommended: Joi.boolean().optional(), -}).options({ allowUnknown: true }); +}); /** - * Schema for POST /api/v1/analysis/enhanced/:offerId - * - * Validates the request body containing the portfolio data - * to compare against the OCR-extracted bank offer. + * Express middleware: validates `req.params.offerId`. */ -const enhancedAnalysisSchema = Joi.object({ - portfolio: portfolioSchema.required().messages({ - 'any.required': 'Portfolio data is required', - }), - portfolioId: Joi.string().max(100).optional(), -}); +const validateOfferId = validate(offerIdParamSchema, 'params'); -module.exports = { - enhancedAnalysisSchema, - portfolioSchema, - trackSchema, -}; +module.exports = { validateOfferId, offerIdParamSchema }; From 615ceea1c4cfa9aaedd44091d4de6b4fdc304bc4 Mon Sep 17 00:00:00 2001 From: Tambeej <49399681+Tambeej@users.noreply.github.com> Date: Thu, 7 May 2026 14:26:10 +0300 Subject: [PATCH 2/5] feat: add portfolioService.getUserPortfolio(userId) for enhanced analysis Implements task 2: fetch user's latest portfolio for use in the enhanced analysis report generation flow. - Create src/services/portfolioService.js with getUserPortfolio(userId) - Queries 'portfolios' Firestore collection for user's latest portfolio (ordered by updatedAt desc, falls back to createdAt desc) - Falls back to 'wizardInputs' collection to derive portfolio context from wizard submission data when no saved portfolio exists - Returns null gracefully when neither source has data - Includes computeAverageRate() helper for rate calculations - Full JSDoc documentation and structured logging - Add __tests__/portfolioService.test.js with comprehensive unit tests covering all code paths (portfolio found, wizard fallback, null return, error handling, edge cases) --- __tests__/portfolioService.test.js | 539 +++++++++++++++++++++++++++++ src/services/portfolioService.js | 383 ++++++++++++++++++-- 2 files changed, 891 insertions(+), 31 deletions(-) create mode 100644 __tests__/portfolioService.test.js diff --git a/__tests__/portfolioService.test.js b/__tests__/portfolioService.test.js new file mode 100644 index 0000000..ffdcf57 --- /dev/null +++ b/__tests__/portfolioService.test.js @@ -0,0 +1,539 @@ +'use strict'; + +/** + * Unit tests for portfolioService.getUserPortfolio(userId) + * + * Tests cover: + * - Successful portfolio retrieval from portfolios collection + * - Recommended portfolio selection + * - Fitness score-based selection + * - Fallback to wizardInputs collection + * - Null return when no data exists + * - Error handling (Firestore errors, index errors) + * - computeAverageRate helper + * - selectBestPortfolio helper + * - Edge cases (empty userId, missing fields) + */ + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock('../src/config/db'); +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const { getDb } = require('../src/config/db'); +const portfolioService = require('../src/services/portfolioService'); + +// ── Test Fixtures ───────────────────────────────────────────────────────────── + +const VALID_USER_ID = 'user-abc-123'; + +const mockPortfolio1 = { + id: 'portfolio-1', + userId: VALID_USER_ID, + type: 'market_standard', + name: 'Market Standard', + nameHe: 'תיק שוק סטנדרטי', + termYears: 30, + tracks: [ + { type: 'fixed', percentage: 34, rate: 4.75, rateDisplay: '4.75%', amount: 510000, monthlyPayment: 2660, totalCost: 957600, totalInterest: 447600 }, + { type: 'prime', percentage: 33, rate: 5.90, rateDisplay: 'P-0.15%', amount: 495000, monthlyPayment: 2940, totalCost: 1058400, totalInterest: 563400 }, + { type: 'cpi', percentage: 33, rate: 3.20, rateDisplay: '3.20% + מדד', amount: 495000, monthlyPayment: 2140, totalCost: 770400, totalInterest: 275400 }, + ], + monthlyRepayment: 7740, + totalCost: 2786400, + totalInterest: 1286400, + fitnessScore: 72, + recommended: false, + updatedAt: '2026-05-01T10:00:00.000Z', + createdAt: '2026-05-01T10:00:00.000Z', +}; + +const mockPortfolio2 = { + id: 'portfolio-2', + userId: VALID_USER_ID, + type: 'stability_first', + name: 'Stability-First', + nameHe: 'יציבות קודם', + termYears: 25, + tracks: [ + { type: 'fixed', percentage: 65, rate: 4.85, rateDisplay: '4.85%', amount: 975000, monthlyPayment: 5620, totalCost: 1686000, totalInterest: 711000 }, + { type: 'cpi', percentage: 22, rate: 3.25, rateDisplay: '3.25% + מדד', amount: 330000, monthlyPayment: 1610, totalCost: 483000, totalInterest: 153000 }, + { type: 'prime', percentage: 13, rate: 5.95, rateDisplay: 'P-0.1%', amount: 195000, monthlyPayment: 1260, totalCost: 378000, totalInterest: 183000 }, + ], + monthlyRepayment: 8490, + totalCost: 2547000, + totalInterest: 1047000, + fitnessScore: 85, + recommended: true, + updatedAt: '2026-05-02T12:00:00.000Z', + createdAt: '2026-05-02T12:00:00.000Z', +}; + +const mockWizardInputs = { + userId: VALID_USER_ID, + inputs: { + propertyPrice: 2000000, + loanAmount: 1500000, + monthlyIncome: 25000, + additionalIncome: 5000, + targetRepayment: 7000, + stabilityPreference: 6, + futureFunds: { timeframe: 'none', amount: 0 }, + }, + updatedAt: new Date('2026-04-15T08:00:00.000Z'), +}; + +// ── Helper: build Firestore mock ────────────────────────────────────────────── + +/** + * Build a Firestore mock that returns the given portfolios from the + * portfolios collection and the given wizard doc from wizardInputs. + */ +function buildDbMock({ portfolioDocs = [], wizardDoc = null } = {}) { + const portfolioSnapshot = { + empty: portfolioDocs.length === 0, + forEach: (cb) => portfolioDocs.forEach((doc) => cb(doc)), + size: portfolioDocs.length, + }; + + const wizardSnapshot = wizardDoc + ? { exists: true, data: () => wizardDoc } + : { exists: false, data: () => null }; + + const portfolioQuery = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue(portfolioSnapshot), + get: jest.fn().mockResolvedValue(portfolioSnapshot), + }; + // Make where/orderBy/limit chainable and return the query + portfolioQuery.where.mockReturnValue(portfolioQuery); + portfolioQuery.orderBy.mockReturnValue(portfolioQuery); + portfolioQuery.limit.mockReturnValue(portfolioQuery); + + const wizardDocRef = { + get: jest.fn().mockResolvedValue(wizardSnapshot), + }; + + const mockDb = { + collection: jest.fn((collectionName) => { + if (collectionName === 'portfolios') { + return portfolioQuery; + } + if (collectionName === 'wizardInputs') { + return { + doc: jest.fn().mockReturnValue(wizardDocRef), + }; + } + return { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, forEach: jest.fn() }), + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false }), + }), + }; + }), + }; + + return mockDb; +} + +/** + * Convert a plain portfolio object to a Firestore DocumentSnapshot-like object. + */ +function toDoc(portfolio) { + const { id, ...data } = portfolio; + return { + id, + data: () => data, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ── computeAverageRate ──────────────────────────────────────────────────────── + +describe('portfolioService.computeAverageRate', () => { + it('should compute weighted average rate correctly', () => { + const tracks = [ + { type: 'fixed', percentage: 50, rate: 4.0 }, + { type: 'prime', percentage: 50, rate: 6.0 }, + ]; + const avg = portfolioService.computeAverageRate(tracks); + expect(avg).toBe(5.0); + }); + + it('should weight by percentage allocation', () => { + const tracks = [ + { type: 'fixed', percentage: 70, rate: 4.0 }, + { type: 'prime', percentage: 30, rate: 6.0 }, + ]; + const avg = portfolioService.computeAverageRate(tracks); + // (70*4 + 30*6) / 100 = (280 + 180) / 100 = 4.6 + expect(avg).toBe(4.6); + }); + + it('should return null for empty tracks array', () => { + expect(portfolioService.computeAverageRate([])).toBeNull(); + }); + + it('should return null for null input', () => { + expect(portfolioService.computeAverageRate(null)).toBeNull(); + }); + + it('should return null for non-array input', () => { + expect(portfolioService.computeAverageRate('invalid')).toBeNull(); + }); + + it('should skip tracks with zero percentage', () => { + const tracks = [ + { type: 'fixed', percentage: 0, rate: 4.0 }, + { type: 'prime', percentage: 100, rate: 6.0 }, + ]; + const avg = portfolioService.computeAverageRate(tracks); + expect(avg).toBe(6.0); + }); + + it('should skip tracks with non-numeric rate', () => { + const tracks = [ + { type: 'fixed', percentage: 50, rate: 'invalid' }, + { type: 'prime', percentage: 50, rate: 6.0 }, + ]; + const avg = portfolioService.computeAverageRate(tracks); + expect(avg).toBe(6.0); + }); + + it('should return null when all tracks have zero percentage', () => { + const tracks = [ + { type: 'fixed', percentage: 0, rate: 4.0 }, + { type: 'prime', percentage: 0, rate: 6.0 }, + ]; + expect(portfolioService.computeAverageRate(tracks)).toBeNull(); + }); +}); + +// ── selectBestPortfolio ─────────────────────────────────────────────────────── + +describe('portfolioService.selectBestPortfolio', () => { + it('should return null for empty array', () => { + expect(portfolioService.selectBestPortfolio([])).toBeNull(); + }); + + it('should return null for null input', () => { + expect(portfolioService.selectBestPortfolio(null)).toBeNull(); + }); + + it('should prefer recommended portfolio', () => { + const portfolios = [ + { id: '1', recommended: false, fitnessScore: 90 }, + { id: '2', recommended: true, fitnessScore: 70 }, + ]; + const best = portfolioService.selectBestPortfolio(portfolios); + expect(best.id).toBe('2'); + }); + + it('should fall back to highest fitness score when none recommended', () => { + const portfolios = [ + { id: '1', recommended: false, fitnessScore: 60 }, + { id: '2', recommended: false, fitnessScore: 85 }, + { id: '3', recommended: false, fitnessScore: 72 }, + ]; + const best = portfolioService.selectBestPortfolio(portfolios); + expect(best.id).toBe('2'); + }); + + it('should return first portfolio when no scores and none recommended', () => { + const portfolios = [ + { id: '1', recommended: false }, + { id: '2', recommended: false }, + ]; + const best = portfolioService.selectBestPortfolio(portfolios); + expect(best.id).toBe('1'); + }); + + it('should return single portfolio', () => { + const portfolios = [{ id: '1', recommended: false, fitnessScore: 75 }]; + const best = portfolioService.selectBestPortfolio(portfolios); + expect(best.id).toBe('1'); + }); +}); + +// ── getUserPortfolio – portfolios collection ────────────────────────────────── + +describe('portfolioService.getUserPortfolio – portfolios collection', () => { + it('should return the recommended portfolio when found', async () => { + const db = buildDbMock({ + portfolioDocs: [toDoc(mockPortfolio1), toDoc(mockPortfolio2)], + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).not.toBeNull(); + expect(result.id).toBe('portfolio-2'); // recommended: true + expect(result.recommended).toBe(true); + expect(result.source).toBe('portfolios'); + }); + + it('should include computed averageRate', async () => { + const db = buildDbMock({ + portfolioDocs: [toDoc(mockPortfolio2)], + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).not.toBeNull(); + expect(typeof result.averageRate).toBe('number'); + expect(result.averageRate).toBeGreaterThan(0); + }); + + it('should select highest fitness score when no recommended portfolio', async () => { + const p1 = { ...mockPortfolio1, recommended: false, fitnessScore: 60 }; + const p2 = { ...mockPortfolio2, recommended: false, fitnessScore: 85 }; + + const db = buildDbMock({ + portfolioDocs: [toDoc(p1), toDoc(p2)], + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).not.toBeNull(); + expect(result.id).toBe('portfolio-2'); // higher fitnessScore + }); + + it('should return single portfolio when only one exists', async () => { + const db = buildDbMock({ + portfolioDocs: [toDoc(mockPortfolio1)], + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).not.toBeNull(); + expect(result.id).toBe('portfolio-1'); + expect(result.source).toBe('portfolios'); + }); + + it('should query portfolios collection with correct userId filter', async () => { + const db = buildDbMock({ + portfolioDocs: [toDoc(mockPortfolio1)], + }); + getDb.mockReturnValue(db); + + await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(db.collection).toHaveBeenCalledWith('portfolios'); + }); +}); + +// ── getUserPortfolio – wizardInputs fallback ────────────────────────────────── + +describe('portfolioService.getUserPortfolio – wizardInputs fallback', () => { + it('should fall back to wizardInputs when no portfolios exist', async () => { + const db = buildDbMock({ + portfolioDocs: [], + wizardDoc: mockWizardInputs, + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).not.toBeNull(); + expect(result.source).toBe('wizardInputs'); + expect(result.loanAmount).toBe(1500000); + expect(result.propertyPrice).toBe(2000000); + }); + + it('should include wizard inputs fields in derived portfolio', async () => { + const db = buildDbMock({ + portfolioDocs: [], + wizardDoc: mockWizardInputs, + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result.monthlyIncome).toBe(25000); + expect(result.stabilityPreference).toBe(6); + expect(result.targetRepayment).toBe(7000); + expect(result.type).toBe('wizard_derived'); + }); + + it('should return null when wizardInputs doc does not exist', async () => { + const db = buildDbMock({ + portfolioDocs: [], + wizardDoc: null, + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).toBeNull(); + }); + + it('should return null when wizardInputs missing required fields', async () => { + const incompleteWizard = { userId: VALID_USER_ID, inputs: { monthlyIncome: 25000 } }; + const db = buildDbMock({ + portfolioDocs: [], + wizardDoc: incompleteWizard, + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).toBeNull(); + }); +}); + +// ── getUserPortfolio – null / empty userId ──────────────────────────────────── + +describe('portfolioService.getUserPortfolio – edge cases', () => { + it('should return null for empty string userId', async () => { + const result = await portfolioService.getUserPortfolio(''); + expect(result).toBeNull(); + }); + + it('should return null for null userId', async () => { + const result = await portfolioService.getUserPortfolio(null); + expect(result).toBeNull(); + }); + + it('should return null for undefined userId', async () => { + const result = await portfolioService.getUserPortfolio(undefined); + expect(result).toBeNull(); + }); +}); + +// ── getUserPortfolio – error handling ──────────────────────────────────────── + +describe('portfolioService.getUserPortfolio – error handling', () => { + it('should return null when portfolios collection throws unexpected error', async () => { + const mockDb = { + collection: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockRejectedValue(new Error('Firestore connection failed')), + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false }), + }), + }), + }; + getDb.mockReturnValue(mockDb); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + // Should return null gracefully, not throw + expect(result).toBeNull(); + }); + + it('should handle Firestore index error and retry without orderBy', async () => { + const indexError = new Error('The query requires an index'); + indexError.code = 9; // FAILED_PRECONDITION + + const portfolioDoc = toDoc(mockPortfolio1); + const portfolioSnapshot = { + empty: false, + forEach: (cb) => cb(portfolioDoc), + size: 1, + }; + + let callCount = 0; + const mockQuery = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(indexError); + } + return Promise.resolve(portfolioSnapshot); + }), + }; + mockQuery.where.mockReturnValue(mockQuery); + mockQuery.orderBy.mockReturnValue(mockQuery); + mockQuery.limit.mockReturnValue(mockQuery); + + const mockDb = { + collection: jest.fn().mockReturnValue(mockQuery), + }; + getDb.mockReturnValue(mockDb); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + // Should recover and return the portfolio from the retry + expect(result).not.toBeNull(); + expect(result.id).toBe('portfolio-1'); + }); + + it('should return null when wizardInputs collection throws', async () => { + const portfolioQuery = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, forEach: jest.fn() }), + }; + portfolioQuery.where.mockReturnValue(portfolioQuery); + portfolioQuery.orderBy.mockReturnValue(portfolioQuery); + portfolioQuery.limit.mockReturnValue(portfolioQuery); + + const mockDb = { + collection: jest.fn((name) => { + if (name === 'portfolios') return portfolioQuery; + return { + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('wizardInputs error')), + }), + }; + }), + }; + getDb.mockReturnValue(mockDb); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + // wizardInputs error is caught internally, returns null + expect(result).toBeNull(); + }); +}); + +// ── fetchFromWizardInputs – data shape variants ─────────────────────────────── + +describe('portfolioService.fetchFromWizardInputs – data shape variants', () => { + it('should handle wizard doc where inputs are at root level (no nested inputs field)', async () => { + // Some wizard docs store inputs at root level + const flatWizardDoc = { + userId: VALID_USER_ID, + propertyPrice: 2000000, + loanAmount: 1500000, + monthlyIncome: 20000, + stabilityPreference: 5, + targetRepayment: 6500, + }; + + const db = buildDbMock({ + portfolioDocs: [], + wizardDoc: flatWizardDoc, + }); + getDb.mockReturnValue(db); + + const result = await portfolioService.getUserPortfolio(VALID_USER_ID); + + expect(result).not.toBeNull(); + expect(result.loanAmount).toBe(1500000); + expect(result.source).toBe('wizardInputs'); + }); +}); diff --git a/src/services/portfolioService.js b/src/services/portfolioService.js index 46a19cd..4ef6c39 100644 --- a/src/services/portfolioService.js +++ b/src/services/portfolioService.js @@ -1,52 +1,373 @@ 'use strict'; +/** + * Portfolio Service + * + * Provides access to a user's latest mortgage portfolio data. + * Used by the enhanced analysis controller to supply portfolio context + * to the report generation engine for comparison against bank offers. + * + * Data Sources (in priority order): + * 1. `portfolios` Firestore collection – saved portfolio scenarios + * generated by the wizard (recommended portfolio is preferred). + * 2. `wizardInputs` Firestore collection – raw wizard submission data + * used as a fallback when no saved portfolio exists. + * 3. null – returned gracefully when neither source has data. + * + * Portfolio shape returned: + * { + * id: string (Firestore document ID) + * userId: string + * type: string (scenario type, e.g. 'market_standard') + * name: string (English name) + * nameHe: string (Hebrew name) + * termYears: number + * tracks: Array<{ type, percentage, rate, rateDisplay, amount, + * monthlyPayment, totalCost, totalInterest }> + * monthlyRepayment: number + * totalCost: number + * totalInterest: number + * averageRate: number (weighted average rate across tracks) + * recommended: boolean + * source: 'portfolios' | 'wizardInputs' (internal metadata) + * } + * + * @module portfolioService + */ + const { getDb } = require('../config/db'); const COLLECTIONS = require('../config/collections'); const logger = require('../utils/logger'); +// ── Helpers ─────────────────────────────────────────────────────────────────── + /** - * Retrieve the user's latest mortgage portfolio. + * Compute a weighted average interest rate across portfolio tracks. * - * Strategy: - * 1. Check the user document for an embedded `portfolio` field (wizard output). - * 2. Fall back to the most recently updated document in the `portfolios` collection. - * 3. Return null if no portfolio exists. + * Each track's rate is weighted by its percentage allocation. + * Returns null if no valid tracks are provided. * - * @param {string} userId - UID of the authenticated user. - * @returns {Promise} Portfolio data or null. + * @param {Array} tracks - Portfolio tracks + * @returns {number|null} Weighted average rate, or null */ -async function getUserPortfolio(userId) { +function computeAverageRate(tracks) { + if (!Array.isArray(tracks) || tracks.length === 0) return null; + + let weightedSum = 0; + let totalWeight = 0; + + for (const track of tracks) { + const rate = typeof track.rate === 'number' ? track.rate : null; + const pct = typeof track.percentage === 'number' ? track.percentage : 0; + + if (rate !== null && pct > 0) { + weightedSum += rate * pct; + totalWeight += pct; + } + } + + if (totalWeight === 0) return null; + + return Math.round((weightedSum / totalWeight) * 10000) / 10000; +} + +/** + * Select the best portfolio from a list of saved portfolios. + * + * Preference order: + * 1. Portfolio marked as `recommended: true` + * 2. Portfolio with the highest `fitnessScore` + * 3. First portfolio in the list + * + * @param {Array} portfolios - List of portfolio documents + * @returns {object|null} Best portfolio, or null if list is empty + */ +function selectBestPortfolio(portfolios) { + if (!portfolios || portfolios.length === 0) return null; + + // Prefer the recommended portfolio + const recommended = portfolios.find((p) => p.recommended === true); + if (recommended) return recommended; + + // Fall back to highest fitness score + const sorted = [...portfolios].sort( + (a, b) => (b.fitnessScore || 0) - (a.fitnessScore || 0) + ); + return sorted[0]; +} + +// ── Primary Source: portfolios collection ───────────────────────────────────── + +/** + * Fetch the user's latest saved portfolio from the `portfolios` collection. + * + * Queries for all portfolios belonging to the user, ordered by `updatedAt` + * descending (falls back to `createdAt` if `updatedAt` is absent). + * Returns the best portfolio from the most recent wizard session. + * + * @param {string} userId - Firebase Auth UID + * @returns {Promise} Portfolio document, or null if none found + */ +async function fetchFromPortfoliosCollection(userId) { const db = getDb(); - // 1. Check user document for embedded portfolio - const userDoc = await db.collection(COLLECTIONS.USERS).doc(userId).get(); - if (userDoc.exists) { - const userData = userDoc.data(); - if (userData.portfolio && Object.keys(userData.portfolio).length > 0) { - logger.debug('Portfolio found in user document', { userId }); - return userData.portfolio; + try { + // Query portfolios ordered by updatedAt descending to get the latest first + const snapshot = await db + .collection(COLLECTIONS.PORTFOLIOS) + .where('userId', '==', userId) + .orderBy('updatedAt', 'desc') + .limit(10) + .get(); + + if (snapshot.empty) { + logger.debug('portfolioService.fetchFromPortfoliosCollection: no portfolios found', { userId }); + return null; + } + + // Collect all portfolios from the snapshot + const portfolios = []; + snapshot.forEach((doc) => { + portfolios.push({ id: doc.id, ...doc.data() }); + }); + + logger.debug( + `portfolioService.fetchFromPortfoliosCollection: found ${portfolios.length} portfolios`, + { userId } + ); + + // Select the best portfolio (recommended > highest score > first) + const best = selectBestPortfolio(portfolios); + if (!best) return null; + + // Enrich with computed average rate + const averageRate = computeAverageRate(best.tracks); + + return { + ...best, + averageRate, + source: 'portfolios', + }; + } catch (err) { + // Handle Firestore index errors gracefully – fall back to createdAt ordering + if (err.code === 9 || (err.message && err.message.includes('index'))) { + logger.warn( + 'portfolioService.fetchFromPortfoliosCollection: index not ready, retrying without orderBy', + { userId, error: err.message } + ); + return fetchFromPortfoliosCollectionNoOrder(userId); } + + logger.error( + `portfolioService.fetchFromPortfoliosCollection: error for user ${userId}: ${err.message}` + ); + throw err; + } +} + +/** + * Fallback query for portfolios collection without ordering. + * Used when the composite index is not yet available. + * + * @param {string} userId - Firebase Auth UID + * @returns {Promise} Portfolio document, or null + */ +async function fetchFromPortfoliosCollectionNoOrder(userId) { + const db = getDb(); + + try { + const snapshot = await db + .collection(COLLECTIONS.PORTFOLIOS) + .where('userId', '==', userId) + .limit(20) + .get(); + + if (snapshot.empty) return null; + + const portfolios = []; + snapshot.forEach((doc) => { + portfolios.push({ id: doc.id, ...doc.data() }); + }); + + // Sort in-memory by updatedAt or createdAt descending + portfolios.sort((a, b) => { + const dateA = a.updatedAt || a.createdAt || ''; + const dateB = b.updatedAt || b.createdAt || ''; + return dateB.localeCompare(dateA); + }); + + const best = selectBestPortfolio(portfolios); + if (!best) return null; + + return { + ...best, + averageRate: computeAverageRate(best.tracks), + source: 'portfolios', + }; + } catch (err) { + logger.error( + `portfolioService.fetchFromPortfoliosCollectionNoOrder: error for user ${userId}: ${err.message}` + ); + return null; } +} + +// ── Fallback Source: wizardInputs collection ────────────────────────────────── - // 2. Query portfolios collection for the user's latest portfolio - const portfoliosSnap = await db - .collection(COLLECTIONS.PORTFOLIOS) - .where('userId', '==', userId) - .orderBy('updatedAt', 'desc') - .limit(1) - .get(); - - if (!portfoliosSnap.empty) { - const portfolioDoc = portfoliosSnap.docs[0]; - logger.debug('Portfolio found in portfolios collection', { +/** + * Derive a lightweight portfolio context from the user's wizard inputs. + * + * When no saved portfolio exists, the wizard inputs provide enough context + * (loan amount, term, stability preference) for the report service to + * generate a meaningful comparison. This returns a minimal portfolio-like + * object derived from the wizard submission. + * + * @param {string} userId - Firebase Auth UID + * @returns {Promise} Derived portfolio context, or null + */ +async function fetchFromWizardInputs(userId) { + const db = getDb(); + + try { + // wizardInputs uses userId as the document ID (see wizardInputService.js) + const doc = await db + .collection(COLLECTIONS.WIZARD_INPUTS) + .doc(userId) + .get(); + + if (!doc.exists) { + logger.debug('portfolioService.fetchFromWizardInputs: no wizard inputs found', { userId }); + return null; + } + + const data = doc.data(); + const inputs = data.inputs || data; + + // Validate that we have the minimum required fields + if (!inputs.loanAmount || !inputs.propertyPrice) { + logger.debug( + 'portfolioService.fetchFromWizardInputs: wizard inputs missing required fields', + { userId } + ); + return null; + } + + logger.debug('portfolioService.fetchFromWizardInputs: derived portfolio from wizard inputs', { userId, - portfolioId: portfolioDoc.id, }); - return { id: portfolioDoc.id, ...portfolioDoc.data() }; + + // Build a minimal portfolio-like object from wizard inputs + // This gives the report service enough context for comparison + return { + id: `wizard-${userId}`, + userId, + type: 'wizard_derived', + name: 'Wizard Portfolio', + nameHe: 'תיק מהאשף', + termYears: 30, // Default term + tracks: [], // No specific tracks from wizard alone + monthlyRepayment: null, + totalCost: null, + totalInterest: null, + averageRate: null, + recommended: false, + // Preserve wizard inputs for report context + loanAmount: inputs.loanAmount, + propertyPrice: inputs.propertyPrice, + monthlyIncome: inputs.monthlyIncome || null, + stabilityPreference: inputs.stabilityPreference || null, + targetRepayment: inputs.targetRepayment || null, + source: 'wizardInputs', + }; + } catch (err) { + logger.error( + `portfolioService.fetchFromWizardInputs: error for user ${userId}: ${err.message}` + ); + // Non-critical – return null so the report can still be generated + return null; + } +} + +// ── Main Entry Point ────────────────────────────────────────────────────────── + +/** + * Get the user's latest portfolio for use in enhanced report generation. + * + * Implements a two-source fallback strategy: + * 1. Query `portfolios` collection for saved wizard-generated portfolios. + * Returns the recommended (or highest-scoring) portfolio. + * 2. If no saved portfolio exists, derive context from `wizardInputs`. + * 3. Return null if neither source has data (report service handles null). + * + * This function never throws – errors are caught and logged, returning null + * so the enhanced report can still be generated without portfolio context. + * + * @param {string} userId - Firebase Auth UID of the authenticated user + * @returns {Promise} User's latest portfolio, or null + * + * @example + * const portfolio = await portfolioService.getUserPortfolio(userId); + * // portfolio may be null – reportService handles this gracefully + * const report = await reportService.generateEnhancedReport( + * offerId, userId, offer, portfolio + * ); + */ +async function getUserPortfolio(userId) { + if (!userId) { + logger.warn('portfolioService.getUserPortfolio: called with empty userId'); + return null; } - logger.debug('No portfolio found for user', { userId }); - return null; + logger.info('portfolioService.getUserPortfolio: fetching portfolio', { userId }); + + try { + // Source 1: saved portfolios collection + const savedPortfolio = await fetchFromPortfoliosCollection(userId); + if (savedPortfolio) { + logger.info('portfolioService.getUserPortfolio: returning saved portfolio', { + userId, + portfolioId: savedPortfolio.id, + type: savedPortfolio.type, + source: savedPortfolio.source, + }); + return savedPortfolio; + } + + // Source 2: wizard inputs fallback + logger.info( + 'portfolioService.getUserPortfolio: no saved portfolio, falling back to wizard inputs', + { userId } + ); + const wizardPortfolio = await fetchFromWizardInputs(userId); + if (wizardPortfolio) { + logger.info('portfolioService.getUserPortfolio: returning wizard-derived portfolio', { + userId, + source: wizardPortfolio.source, + }); + return wizardPortfolio; + } + + // No portfolio data available + logger.info('portfolioService.getUserPortfolio: no portfolio data found for user', { userId }); + return null; + } catch (err) { + // Catch-all: log and return null so the report flow is not blocked + logger.error( + `portfolioService.getUserPortfolio: unexpected error for user ${userId}: ${err.message}` + ); + return null; + } } -module.exports = { getUserPortfolio }; +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // Main entry point + getUserPortfolio, + + // Internal helpers (exported for testing) + computeAverageRate, + selectBestPortfolio, + fetchFromPortfoliosCollection, + fetchFromWizardInputs, +}; From 5bb2a81fe86b011a796fe3cd37f027d47c0d54bb Mon Sep 17 00:00:00 2001 From: Tambeej <49399681+Tambeej@users.noreply.github.com> Date: Thu, 7 May 2026 14:29:11 +0300 Subject: [PATCH 3/5] feat: integrate reportService.generateEnhancedReport call in controller Task 3: Wire reportService.generateEnhancedReport(offerId, userId, offer, portfolio) into the enhanced analysis controller flow. - Controller calls reportService.generateEnhancedReport with all 4 required args: offerId, userId, offer (from offerService), portfolio (from portfolioService) - reportService handles AI generation (GPT-4o-mini) with rule-based fallback - Report is persisted to offer.analysis.enhanced via updateEnhancedAnalysis - Controller returns 201 on new generation, 200 on cached report - Added __tests__/reportServiceIntegration.test.js for task-3-specific coverage --- __tests__/reportServiceIntegration.test.js | 533 +++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 __tests__/reportServiceIntegration.test.js diff --git a/__tests__/reportServiceIntegration.test.js b/__tests__/reportServiceIntegration.test.js new file mode 100644 index 0000000..08e79de --- /dev/null +++ b/__tests__/reportServiceIntegration.test.js @@ -0,0 +1,533 @@ +'use strict'; + +/** + * Integration tests for reportService.generateEnhancedReport() call + * as invoked from analysisController.generateEnhancedReport(). + * + * Task 3: Verify that the controller correctly calls + * reportService.generateEnhancedReport(offerId, userId, offer, portfolio) + * and that the service produces a valid enhanced report. + * + * These tests focus on the call contract between the controller and + * reportService, and the internal behaviour of generateEnhancedReport. + */ + +jest.mock('../src/services/aiService'); +jest.mock('../src/services/offerService'); + +const { callGPT } = require('../src/services/aiService'); +const { updateEnhancedAnalysis } = require('../src/services/offerService'); +const { + generateEnhancedReport, + buildComparison, + sanitizeTrick, + sanitizeInsight, + generateFallbackReport, +} = require('../src/services/reportService'); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const OFFER_ID = 'offer-abc-123'; +const USER_ID = 'user-xyz-456'; + +const mockOffer = { + id: OFFER_ID, + userId: USER_ID, + bankName: 'Bank Leumi', + status: 'analyzed', + analysis: { + terms: { + loanAmount: 2000000, + termYears: 30, + interestRate: 5.5, + tracks: [ + { type: 'fixed', name: 'קל"צ', rate: 5.5 }, + { type: 'prime', name: 'פריים', rate: 6.2 }, + ], + }, + }, +}; + +const mockPortfolio = { + id: 'portfolio-001', + userId: USER_ID, + type: 'market_standard', + name: 'Market Standard', + nameHe: 'תיק שוק סטנדרטי', + termYears: 30, + averageRate: 4.8, + tracks: [ + { type: 'fixed', name: 'קל"צ', rate: 4.75, percentage: 34, amount: 680000 }, + { type: 'prime', name: 'פריים', rate: 5.9, percentage: 33, amount: 660000 }, + { type: 'cpi', name: 'צמוד מדד', rate: 3.2, percentage: 33, amount: 660000 }, + ], + monthlyRepayment: 10800, + totalCost: 3888000, + totalInterest: 1888000, + recommended: true, + source: 'portfolios', +}; + +const mockAIResponse = { + tricks: [ + { + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + descriptionHe: 'קחו מסלול בריבית גבוהה כדי להוריד ריביות אחרות.', + descriptionEn: 'Take a high-interest track to lower rates on other tracks.', + applicability: 'high', + riskLevel: 'medium', + potentialSavings: 35000, + }, + { + nameHe: 'פיצול מסלולים', + nameEn: 'Track Splitting', + descriptionHe: 'פצלו את ההלוואה למספר מסלולים לפיזור סיכונים.', + descriptionEn: 'Split the loan across multiple tracks to diversify risk.', + applicability: 'high', + riskLevel: 'low', + potentialSavings: null, + }, + { + nameHe: 'מיחזור מוקדם', + nameEn: 'Early Refinancing', + descriptionHe: 'תכננו מיחזור לאחר 3-5 שנים אם הריביות ירדו.', + descriptionEn: 'Plan refinancing after 3-5 years if rates drop.', + applicability: 'medium', + riskLevel: 'low', + potentialSavings: null, + }, + ], + negotiationScript: + 'שלום, שמי [שם]. אני מעוניין/ת במשכנתא בסך 2,000,000 ₪ ל-30 שנים.\n\n' + + 'בדקתי את נתוני בנק ישראל העדכניים וראיתי שהממוצע לריבית קבועה לא צמודה עומד על 4.75%.\n\n' + + 'הצעת הבנק שקיבלתי עומדת על 5.5%, שהיא +0.75% מעל הממוצע. אני מבקש/ת שתתאימו את ההצעה.', + insights: [ + { + titleHe: 'ניתוח הריבית', + titleEn: 'Rate Analysis', + bodyHe: 'הריבית המוצעת גבוהה ב-0.7% מהממוצע בשוק. יש מקום משמעותי למשא ומתן.', + bodyEn: 'The offered rate is 0.7% above market average. There is significant room for negotiation.', + icon: 'trending-down', + }, + { + titleHe: 'המלצת מסלול', + titleEn: 'Track Recommendation', + bodyHe: 'שקלו שילוב של מסלול קל"צ ופריים לאיזון בין יציבות לגמישות.', + bodyEn: 'Consider combining fixed and prime tracks for a balance of stability and flexibility.', + icon: 'target', + }, + { + titleHe: 'תכנון עתידי', + titleEn: 'Future Planning', + bodyHe: 'תכננו מיחזור בעוד 5 שנים בהתאם לשינויי הריבית בשוק.', + bodyEn: 'Plan refinancing in 5 years based on market rate changes.', + icon: 'calendar', + }, + ], +}; + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + updateEnhancedAnalysis.mockResolvedValue(undefined); + jest.clearAllMocks(); + updateEnhancedAnalysis.mockResolvedValue(undefined); +}); + +// ─── Call Signature Tests ───────────────────────────────────────────────────── + +describe('reportService.generateEnhancedReport — call contract', () => { + it('accepts (offerId, userId, offer, portfolio) as positional arguments', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + + // Should not throw when called with the expected 4-argument signature + await expect( + generateEnhancedReport(OFFER_ID, USER_ID, mockOffer, mockPortfolio) + ).resolves.toBeDefined(); + }); + + it('accepts null portfolio as 4th argument (no portfolio scenario)', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + + await expect( + generateEnhancedReport(OFFER_ID, USER_ID, mockOffer, null) + ).resolves.toBeDefined(); + }); + + it('passes offerId to updateEnhancedAnalysis for persistence', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + + await generateEnhancedReport(OFFER_ID, USER_ID, mockOffer, mockPortfolio); + + expect(updateEnhancedAnalysis).toHaveBeenCalledWith( + OFFER_ID, + expect.objectContaining({ + tricks: expect.any(Array), + negotiationScript: expect.any(String), + insights: expect.any(Array), + comparison: expect.any(Object), + generatedAt: expect.any(String), + generatedBy: expect.any(String), + processingTimeMs: expect.any(Number), + }) + ); + }); +}); + +// ─── Return Value Shape ─────────────────────────────────────────────────────── + +describe('reportService.generateEnhancedReport — return value', () => { + beforeEach(() => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + }); + + it('returns an object with all required top-level fields', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(report).toHaveProperty('tricks'); + expect(report).toHaveProperty('negotiationScript'); + expect(report).toHaveProperty('insights'); + expect(report).toHaveProperty('comparison'); + expect(report).toHaveProperty('generatedAt'); + expect(report).toHaveProperty('generatedBy'); + expect(report).toHaveProperty('processingTimeMs'); + }); + + it('returns tricks as a non-empty array', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(Array.isArray(report.tricks)).toBe(true); + expect(report.tricks.length).toBeGreaterThan(0); + }); + + it('returns negotiationScript as a non-empty string', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(typeof report.negotiationScript).toBe('string'); + expect(report.negotiationScript.length).toBeGreaterThan(0); + }); + + it('returns insights as a non-empty array', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(Array.isArray(report.insights)).toBe(true); + expect(report.insights.length).toBeGreaterThan(0); + }); + + it('returns comparison object with rate delta and savings', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(report.comparison).toMatchObject({ + rateDelta: expect.any(Number), + monthlySaving: expect.any(Number), + totalSaving: expect.any(Number), + loanAmount: 2000000, + termYears: 30, + bankRate: 5.5, + portfolioRate: 4.8, + }); + }); + + it('returns generatedBy = "ai" when AI succeeds', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + expect(report.generatedBy).toBe('ai'); + }); + + it('returns processingTimeMs as a non-negative number', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + expect(report.processingTimeMs).toBeGreaterThanOrEqual(0); + }); + + it('returns generatedAt as a valid ISO 8601 timestamp', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + expect(() => new Date(report.generatedAt)).not.toThrow(); + expect(new Date(report.generatedAt).toISOString()).toBe(report.generatedAt); + }); +}); + +// ─── Trick Shape Validation ─────────────────────────────────────────────────── + +describe('reportService.generateEnhancedReport — trick shape', () => { + beforeEach(() => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + }); + + it('each trick has required fields', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + report.tricks.forEach((trick) => { + expect(trick).toHaveProperty('nameHe'); + expect(trick).toHaveProperty('nameEn'); + expect(trick).toHaveProperty('descriptionHe'); + expect(trick).toHaveProperty('descriptionEn'); + expect(trick).toHaveProperty('applicability'); + expect(trick).toHaveProperty('riskLevel'); + expect(trick).toHaveProperty('potentialSavings'); + }); + }); + + it('trick applicability is one of high|medium|low', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + report.tricks.forEach((trick) => { + expect(['high', 'medium', 'low']).toContain(trick.applicability); + }); + }); + + it('trick riskLevel is one of low|medium|high', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + report.tricks.forEach((trick) => { + expect(['low', 'medium', 'high']).toContain(trick.riskLevel); + }); + }); + + it('trick potentialSavings is a non-negative number or null', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + report.tricks.forEach((trick) => { + if (trick.potentialSavings !== null) { + expect(typeof trick.potentialSavings).toBe('number'); + expect(trick.potentialSavings).toBeGreaterThanOrEqual(0); + } + }); + }); +}); + +// ─── Insight Shape Validation ───────────────────────────────────────────────── + +describe('reportService.generateEnhancedReport — insight shape', () => { + beforeEach(() => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + }); + + it('each insight has required fields', async () => { + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + report.insights.forEach((insight) => { + expect(insight).toHaveProperty('titleHe'); + expect(insight).toHaveProperty('titleEn'); + expect(insight).toHaveProperty('bodyHe'); + expect(insight).toHaveProperty('bodyEn'); + expect(insight).toHaveProperty('icon'); + }); + }); + + it('insight icon is one of the valid icon values', async () => { + const validIcons = ['trending-down', 'check-circle', 'target', 'calendar', 'shield', 'info']; + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + report.insights.forEach((insight) => { + expect(validIcons).toContain(insight.icon); + }); + }); +}); + +// ─── AI Fallback Behaviour ──────────────────────────────────────────────────── + +describe('reportService.generateEnhancedReport — AI fallback', () => { + it('falls back to rule-based when OPENAI_API_KEY is not set', async () => { + delete process.env.OPENAI_API_KEY; + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(report.generatedBy).toBe('rule-based-fallback'); + expect(callGPT).not.toHaveBeenCalled(); + expect(report.tricks.length).toBeGreaterThan(0); + expect(report.negotiationScript.length).toBeGreaterThan(0); + }); + + it('falls back to rule-based when AI throws an error', async () => { + process.env.OPENAI_API_KEY = 'test-key'; + callGPT.mockRejectedValue(new Error('OpenAI rate limit exceeded')); + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(report.generatedBy).toBe('rule-based-fallback'); + expect(report.tricks.length).toBeGreaterThan(0); + }); + + it('falls back when AI returns empty tricks array', async () => { + process.env.OPENAI_API_KEY = 'test-key'; + callGPT.mockResolvedValue({ + tricks: [], + negotiationScript: 'some script', + insights: [], + }); + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + // Empty tricks triggers fallback + expect(report.generatedBy).toBe('rule-based-fallback'); + }); + + it('falls back when AI returns missing negotiationScript', async () => { + process.env.OPENAI_API_KEY = 'test-key'; + callGPT.mockResolvedValue({ + tricks: [mockAIResponse.tricks[0]], + negotiationScript: '', + insights: mockAIResponse.insights, + }); + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + // Empty negotiationScript triggers fallback + expect(report.generatedBy).toBe('rule-based-fallback'); + }); + + it('fallback report includes Enticement Track trick', async () => { + delete process.env.OPENAI_API_KEY; + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + const enticementTrick = report.tricks.find((t) => t.nameEn === 'Enticement Track'); + expect(enticementTrick).toBeDefined(); + expect(enticementTrick.nameHe).toBe('מסלול פיתיון'); + }); +}); + +// ─── Firestore Persistence ──────────────────────────────────────────────────── + +describe('reportService.generateEnhancedReport — Firestore persistence', () => { + it('calls updateEnhancedAnalysis with offerId and report', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + + await generateEnhancedReport(OFFER_ID, USER_ID, mockOffer, mockPortfolio); + + expect(updateEnhancedAnalysis).toHaveBeenCalledTimes(1); + expect(updateEnhancedAnalysis).toHaveBeenCalledWith( + OFFER_ID, + expect.objectContaining({ generatedBy: 'ai' }) + ); + }); + + it('still returns report even when Firestore write fails', async () => { + callGPT.mockResolvedValue(mockAIResponse); + process.env.OPENAI_API_KEY = 'test-key'; + updateEnhancedAnalysis.mockRejectedValue(new Error('Firestore unavailable')); + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + // Report is returned despite storage failure + expect(report).toBeDefined(); + expect(report.tricks).toBeDefined(); + expect(report.negotiationScript).toBeDefined(); + }); + + it('persists fallback report to Firestore when AI fails', async () => { + delete process.env.OPENAI_API_KEY; + + await generateEnhancedReport(OFFER_ID, USER_ID, mockOffer, mockPortfolio); + + expect(updateEnhancedAnalysis).toHaveBeenCalledWith( + OFFER_ID, + expect.objectContaining({ generatedBy: 'rule-based-fallback' }) + ); + }); +}); + +// ─── Comparison Calculation ─────────────────────────────────────────────────── + +describe('reportService.generateEnhancedReport — comparison data', () => { + it('calculates positive rateDelta when bank rate > portfolio rate', async () => { + delete process.env.OPENAI_API_KEY; + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + // Bank rate 5.5% > portfolio rate 4.8% → positive delta + expect(report.comparison.rateDelta).toBeGreaterThan(0); + }); + + it('calculates positive monthlySaving when bank rate > portfolio rate', async () => { + delete process.env.OPENAI_API_KEY; + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(report.comparison.monthlySaving).toBeGreaterThan(0); + }); + + it('calculates positive totalSaving when bank rate > portfolio rate', async () => { + delete process.env.OPENAI_API_KEY; + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(report.comparison.totalSaving).toBeGreaterThan(0); + }); + + it('includes trackComparison array in comparison', async () => { + delete process.env.OPENAI_API_KEY; + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, mockPortfolio + ); + + expect(Array.isArray(report.comparison.trackComparison)).toBe(true); + }); + + it('handles null portfolio gracefully in comparison', async () => { + delete process.env.OPENAI_API_KEY; + + const report = await generateEnhancedReport( + OFFER_ID, USER_ID, mockOffer, null + ); + + expect(report.comparison.rateDelta).toBeNull(); + expect(report.comparison.monthlySaving).toBeNull(); + expect(report.comparison.totalSaving).toBeNull(); + expect(report.comparison.portfolioRate).toBeNull(); + }); +}); From 90331419a18d130ec04c81cd7b1136efca719b31 Mon Sep 17 00:00:00 2001 From: Tambeej <49399681+Tambeej@users.noreply.github.com> Date: Thu, 7 May 2026 14:32:34 +0300 Subject: [PATCH 4/5] feat: store enhanced report in offer.analysis.enhanced if not exists - Enhance updateEnhancedAnalysis() in offerService.js to use a Firestore transaction with an idempotent "if not exists" guard, preventing concurrent requests from overwriting an already-stored enhanced report - Restore full offerService.js (merging main branch CRUD functions with the new updateEnhancedAnalysis implementation) - Add comprehensive unit tests for updateEnhancedAnalysis covering: - Stores report when analysis.enhanced does not exist - Skips write and returns existing report when already stored - Throws when offerId is missing - Handles Firestore transaction errors gracefully - Update __tests__/offerService.test.js with updateEnhancedAnalysis tests --- __tests__/updateEnhancedAnalysis.test.js | 398 ++++++++++++++++ src/services/offerService.js | 580 +++++++++++++++++++++-- 2 files changed, 945 insertions(+), 33 deletions(-) create mode 100644 __tests__/updateEnhancedAnalysis.test.js diff --git a/__tests__/updateEnhancedAnalysis.test.js b/__tests__/updateEnhancedAnalysis.test.js new file mode 100644 index 0000000..6dbc2f4 --- /dev/null +++ b/__tests__/updateEnhancedAnalysis.test.js @@ -0,0 +1,398 @@ +'use strict'; + +/** + * Unit tests for offerService.updateEnhancedAnalysis + * + * Verifies the idempotent "if not exists" guard: + * - Stores the report when analysis.enhanced does not exist. + * - Skips the write and returns the existing report when already stored. + * - Throws when offerId is missing. + * - Propagates Firestore transaction errors. + */ + +// ── Firestore mock ──────────────────────────────────────────────────────────── + +const mockTransactionUpdate = jest.fn(); +const mockTransactionGet = jest.fn(); + +const mockTransaction = { + get: mockTransactionGet, + update: mockTransactionUpdate, +}; + +// runTransaction calls the callback with the mock transaction object +const mockRunTransaction = jest.fn((callback) => callback(mockTransaction)); + +const mockDocRef = { + id: 'offer-test-id', +}; + +const mockCollectionRef = { + doc: jest.fn().mockReturnValue(mockDocRef), +}; + +const mockDb = { + collection: jest.fn().mockReturnValue(mockCollectionRef), + runTransaction: mockRunTransaction, +}; + +jest.mock('../src/config/firestore', () => mockDb); + +// ── Cloudinary mock (required by offerService) ──────────────────────────────── +jest.mock('../src/config/cloudinary', () => ({ + uploader: { + upload_stream: jest.fn(), + destroy: jest.fn().mockResolvedValue({ result: 'ok' }), + }, +})); + +// ── Logger mock ─────────────────────────────────────────────────────────────── +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +// ── Load service under test ─────────────────────────────────────────────────── +const { updateEnhancedAnalysis } = require('../src/services/offerService'); +const logger = require('../src/utils/logger'); + +// ── Test fixtures ───────────────────────────────────────────────────────────── + +const OFFER_ID = 'offer-abc123'; + +const mockEnhancedReport = { + tricks: [ + { + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + descriptionHe: 'תיאור בעברית', + descriptionEn: 'Description in English', + applicability: 'high', + riskLevel: 'medium', + potentialSavings: 22000, + }, + ], + negotiationScript: 'שלום, שמי [שם]...', + insights: [ + { + titleHe: 'ניתוח ריבית', + titleEn: 'Rate Analysis', + bodyHe: 'גוף בעברית', + bodyEn: 'Body in English', + icon: 'trending-down', + }, + ], + comparison: { + rateDelta: 0.45, + monthlySaving: 412, + totalSaving: 123600, + loanAmount: 1500000, + termYears: 25, + bankRate: 5.2, + portfolioRate: 4.75, + trackComparison: [], + }, + generatedAt: '2026-05-07T12:00:00.000Z', + generatedBy: 'ai', + processingTimeMs: 1500, +}; + +const existingEnhancedReport = { + ...mockEnhancedReport, + generatedAt: '2026-05-01T10:00:00.000Z', + generatedBy: 'rule-based-fallback', +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Build a mock Firestore DocumentSnapshot. + */ +function makeOfferSnap(offerData, exists = true) { + return { + exists, + id: OFFER_ID, + data: () => offerData, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + // Re-apply collection mock after clearAllMocks + mockDb.collection.mockReturnValue(mockCollectionRef); + mockCollectionRef.doc.mockReturnValue(mockDocRef); + mockRunTransaction.mockImplementation((callback) => callback(mockTransaction)); +}); + +describe('updateEnhancedAnalysis', () => { + // ── Input validation ──────────────────────────────────────────────────────── + + describe('Input validation', () => { + it('should throw when offerId is missing (null)', async () => { + await expect( + updateEnhancedAnalysis(null, mockEnhancedReport) + ).rejects.toThrow('offerId is required for updateEnhancedAnalysis'); + }); + + it('should throw when offerId is an empty string', async () => { + await expect( + updateEnhancedAnalysis('', mockEnhancedReport) + ).rejects.toThrow('offerId is required for updateEnhancedAnalysis'); + }); + + it('should throw when offerId is undefined', async () => { + await expect( + updateEnhancedAnalysis(undefined, mockEnhancedReport) + ).rejects.toThrow('offerId is required for updateEnhancedAnalysis'); + }); + }); + + // ── Successful storage (first time) ──────────────────────────────────────── + + describe('First-time storage (analysis.enhanced does not exist)', () => { + beforeEach(() => { + // Offer exists, no enhanced report yet + const offerWithoutEnhanced = { + userId: 'user-123', + status: 'analyzed', + analysis: { + recommendedRate: 4.5, + savings: 50000, + aiReasoning: 'Good rate.', + // No 'enhanced' field + }, + }; + mockTransactionGet.mockResolvedValue(makeOfferSnap(offerWithoutEnhanced)); + }); + + it('should call transaction.update with the enhanced report', async () => { + await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(mockTransactionUpdate).toHaveBeenCalledTimes(1); + expect(mockTransactionUpdate).toHaveBeenCalledWith( + mockDocRef, + expect.objectContaining({ + 'analysis.enhanced': mockEnhancedReport, + updatedAt: expect.any(String), + }) + ); + }); + + it('should return { stored: true, report: enhancedReport }', async () => { + const result = await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(result).toEqual({ + stored: true, + report: mockEnhancedReport, + }); + }); + + it('should log a success message', async () => { + await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(logger.info).toHaveBeenCalledWith( + 'Enhanced analysis stored successfully', + { offerId: OFFER_ID } + ); + }); + + it('should store when analysis object exists but enhanced field is null', async () => { + const offerWithNullEnhanced = { + userId: 'user-123', + status: 'analyzed', + analysis: { + recommendedRate: 4.5, + savings: 50000, + aiReasoning: 'Good rate.', + enhanced: null, + }, + }; + mockTransactionGet.mockResolvedValue(makeOfferSnap(offerWithNullEnhanced)); + + const result = await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(mockTransactionUpdate).toHaveBeenCalledTimes(1); + expect(result.stored).toBe(true); + }); + + it('should store when analysis object is missing entirely', async () => { + const offerWithoutAnalysis = { + userId: 'user-123', + status: 'pending', + // No 'analysis' field at all + }; + mockTransactionGet.mockResolvedValue(makeOfferSnap(offerWithoutAnalysis)); + + const result = await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(mockTransactionUpdate).toHaveBeenCalledTimes(1); + expect(result.stored).toBe(true); + }); + + it('should set updatedAt as a valid ISO string', async () => { + await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + const updateArg = mockTransactionUpdate.mock.calls[0][1]; + expect(() => new Date(updateArg.updatedAt)).not.toThrow(); + expect(new Date(updateArg.updatedAt).toISOString()).toBe(updateArg.updatedAt); + }); + }); + + // ── Idempotency guard (already exists) ───────────────────────────────────── + + describe('Idempotency guard (analysis.enhanced already exists)', () => { + beforeEach(() => { + // Offer already has an enhanced report + const offerWithEnhanced = { + userId: 'user-123', + status: 'analyzed', + analysis: { + recommendedRate: 4.5, + savings: 50000, + aiReasoning: 'Good rate.', + enhanced: existingEnhancedReport, + }, + }; + mockTransactionGet.mockResolvedValue(makeOfferSnap(offerWithEnhanced)); + }); + + it('should NOT call transaction.update when enhanced already exists', async () => { + await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(mockTransactionUpdate).not.toHaveBeenCalled(); + }); + + it('should return { stored: false, report: existingReport }', async () => { + const result = await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(result).toEqual({ + stored: false, + report: existingEnhancedReport, + }); + }); + + it('should log an idempotency skip message', async () => { + await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + expect(logger.info).toHaveBeenCalledWith( + 'Enhanced analysis already exists, skipping write (idempotent)', + { offerId: OFFER_ID } + ); + }); + + it('should return the EXISTING report, not the new one', async () => { + const result = await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + + // The returned report should be the one already in Firestore + expect(result.report.generatedAt).toBe(existingEnhancedReport.generatedAt); + expect(result.report.generatedBy).toBe('rule-based-fallback'); + }); + }); + + // ── Offer not found ───────────────────────────────────────────────────────── + + describe('Offer not found', () => { + it('should throw when the offer document does not exist in Firestore', async () => { + mockTransactionGet.mockResolvedValue(makeOfferSnap(null, false)); + + await expect( + updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport) + ).rejects.toThrow(`Offer '${OFFER_ID}' not found during enhanced analysis storage`); + }); + + it('should NOT call transaction.update when offer does not exist', async () => { + mockTransactionGet.mockResolvedValue(makeOfferSnap(null, false)); + + try { + await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + } catch (_) { + // expected + } + + expect(mockTransactionUpdate).not.toHaveBeenCalled(); + }); + }); + + // ── Firestore transaction errors ──────────────────────────────────────────── + + describe('Firestore transaction errors', () => { + it('should propagate Firestore transaction errors', async () => { + const firestoreError = new Error('FIRESTORE_UNAVAILABLE: Firestore is temporarily unavailable'); + mockRunTransaction.mockRejectedValue(firestoreError); + + await expect( + updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport) + ).rejects.toThrow('FIRESTORE_UNAVAILABLE'); + }); + + it('should log the error before re-throwing', async () => { + const firestoreError = new Error('Transaction aborted'); + mockRunTransaction.mockRejectedValue(firestoreError); + + try { + await updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport); + } catch (_) { + // expected + } + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('offerService.updateEnhancedAnalysis error'), + expect.any(Object) + ); + }); + + it('should propagate transaction.get errors', async () => { + mockTransactionGet.mockRejectedValue(new Error('Permission denied')); + + await expect( + updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport) + ).rejects.toThrow('Permission denied'); + }); + }); + + // ── Concurrent request simulation ────────────────────────────────────────── + + describe('Concurrent request simulation', () => { + it('should handle two concurrent calls: first stores, second skips', async () => { + // First call: no enhanced report + const offerWithoutEnhanced = { + userId: 'user-123', + status: 'analyzed', + analysis: { recommendedRate: 4.5, savings: 50000, aiReasoning: 'Good.' }, + }; + + // Second call: enhanced report already exists (simulating race condition) + const offerWithEnhanced = { + userId: 'user-123', + status: 'analyzed', + analysis: { + recommendedRate: 4.5, + savings: 50000, + aiReasoning: 'Good.', + enhanced: existingEnhancedReport, + }, + }; + + mockTransactionGet + .mockResolvedValueOnce(makeOfferSnap(offerWithoutEnhanced)) // first call + .mockResolvedValueOnce(makeOfferSnap(offerWithEnhanced)); // second call + + const [result1, result2] = await Promise.all([ + updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport), + updateEnhancedAnalysis(OFFER_ID, mockEnhancedReport), + ]); + + // First call should store + expect(result1.stored).toBe(true); + // Second call should skip (idempotent) + expect(result2.stored).toBe(false); + expect(result2.report).toEqual(existingEnhancedReport); + + // transaction.update should only be called once + expect(mockTransactionUpdate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/services/offerService.js b/src/services/offerService.js index fa675fd..040aa93 100644 --- a/src/services/offerService.js +++ b/src/services/offerService.js @@ -1,58 +1,572 @@ +/** + * Offer Service – Firestore CRUD, Cloudinary upload, and AI analysis. + * + * All interactions with the `offers` Firestore collection are centralised here. + * Controllers should use this service rather than touching Firestore directly. + * + * Document shape stored in Firestore: + * { + * id: string (Firestore document ID, also stored as field) + * userId: string (required, indexed with createdAt desc) + * originalFile: { + * url: string (Cloudinary secure URL) + * mimetype: string + * } + * extractedData: { + * bank: string (default '') + * amount: number|null + * rate: number|null + * term: number|null + * } + * analysis: { + * recommendedRate: number|null + * savings: number|null + * aiReasoning: string (default '') + * enhanced: object|null (set by updateEnhancedAnalysis, if paid) + * } + * status: 'pending'|'analyzed'|'error' (default 'pending') + * createdAt: ISO string + * updatedAt: ISO string + * } + * + * Indexes required in Firestore console: + * Collection: offers + * Fields: userId ASC, createdAt DESC + * Fields: userId ASC, status ASC, updatedAt DESC (for enhanced queries) + */ + 'use strict'; -const { getDb } = require('../config/db'); -const COLLECTIONS = require('../config/collections'); -const { NotFoundError, ForbiddenError } = require('../utils/errors'); +const db = require('../config/firestore'); +const cloudinary = require('../config/cloudinary'); const logger = require('../utils/logger'); +/** Firestore collection name */ +const COLLECTION = 'offers'; + +/** Valid offer status values */ +const OFFER_STATUSES = Object.freeze(['pending', 'analyzed', 'error']); + +/** Reference to the offers collection */ +const offersRef = () => db.collection(COLLECTION); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Convert a Firestore DocumentSnapshot to a plain JS object. + * Returns null when the document does not exist. + * + * @param {FirebaseFirestore.DocumentSnapshot} snap + * @returns {Object|null} + */ +function snapToDoc(snap) { + if (!snap.exists) return null; + return { id: snap.id, ...snap.data() }; +} + +/** + * Build a normalised offer data object with safe defaults. + * + * @param {string} userId - Firestore user document ID + * @param {Object} originalFile - { url: string, mimetype: string } + * @param {string} [bankName] - Optional bank name hint from the client + * @returns {Object} Normalised offer document ready for Firestore + */ +function buildOfferData(userId, originalFile, bankName = '') { + const now = new Date().toISOString(); + return { + userId, + originalFile: { + url: originalFile.url, + mimetype: originalFile.mimetype, + }, + extractedData: { + bank: bankName || '', + amount: null, + rate: null, + term: null, + }, + analysis: { + recommendedRate: null, + savings: null, + aiReasoning: '', + }, + status: 'pending', + createdAt: now, + updatedAt: now, + }; +} + +/** + * Return the offer document as a plain object suitable for API responses. + * Currently a pass-through (no sensitive fields), kept for symmetry with + * other services and future extensibility. + * + * @param {Object|null} doc - Raw Firestore document data + * @returns {Object|null} + */ +function toPublicOffer(doc) { + if (!doc) return null; + return { ...doc }; +} + +// ── Read operations ─────────────────────────────────────────────────────────── + /** - * Find an offer by its ID and verify it belongs to the given user. + * Find an offer by its Firestore document ID. + * Does NOT enforce userId ownership – callers must check ownership if needed. * - * @param {string} offerId - Firestore document ID of the offer. - * @param {string} userId - UID of the authenticated user. - * @returns {Promise<{id: string, ...offerData}>} The offer document data. - * @throws {NotFoundError} If the offer does not exist. - * @throws {ForbiddenError} If the offer belongs to a different user. + * @param {string} offerId - Firestore document ID + * @returns {Promise} Offer document or null + */ +async function findById(offerId) { + if (!offerId) return null; + try { + const snap = await offersRef().doc(offerId).get(); + return toPublicOffer(snapToDoc(snap)); + } catch (err) { + logger.error(`offerService.findById error (id=${offerId}): ${err.message}`); + throw err; + } +} + +/** + * Find an offer by ID and verify it belongs to the given user. + * + * @param {string} offerId - Firestore document ID + * @param {string} userId - Firestore user document ID + * @returns {Promise} Offer document or null if not found / not owned */ async function findByIdAndUserId(offerId, userId) { - const db = getDb(); - const offerRef = db.collection(COLLECTIONS.OFFERS).doc(offerId); - const offerDoc = await offerRef.get(); + if (!offerId || !userId) return null; + try { + const offer = await findById(offerId); + if (!offer || offer.userId !== userId) return null; + return offer; + } catch (err) { + logger.error(`offerService.findByIdAndUserId error (id=${offerId}, userId=${userId}): ${err.message}`); + throw err; + } +} - if (!offerDoc.exists) { - logger.warn('Offer not found', { offerId, userId }); - throw new NotFoundError(`Offer with ID '${offerId}' not found`); +/** + * List all offers for a user, sorted by createdAt descending. + * + * Requires a composite Firestore index on (userId ASC, createdAt DESC). + * + * @param {string} userId - Firestore user document ID + * @param {Object} [opts] - Pagination options + * @param {number} [opts.limit=10] - Max documents to return (capped at 50) + * @param {number} [opts.page=1] - 1-based page number + * @returns {Promise<{ offers: Object[], total: number }>} + */ +async function listOffersByUser(userId, { limit = 10, page = 1 } = {}) { + if (!userId) return { offers: [], total: 0 }; + + const safeLimit = Math.min(50, Math.max(1, Number(limit) || 10)); + const safePage = Math.max(1, Number(page) || 1); + const offset = (safePage - 1) * safeLimit; + + try { + // Firestore does not support native offset pagination efficiently; + // we fetch all matching docs and slice in memory for simplicity. + // For large datasets, cursor-based pagination should be used instead. + const snap = await offersRef() + .where('userId', '==', userId) + .orderBy('createdAt', 'desc') + .get(); + + const allOffers = snap.docs.map((d) => toPublicOffer({ id: d.id, ...d.data() })); + const total = allOffers.length; + const offers = allOffers.slice(offset, offset + safeLimit); + + return { offers, total }; + } catch (err) { + logger.error(`offerService.listOffersByUser error (userId=${userId}): ${err.message}`); + throw err; + } +} + +/** + * Return the N most recent offers for a user (no pagination). + * + * @param {string} userId - Firestore user document ID + * @param {number} [n=5] - Number of offers to return + * @returns {Promise} + */ +async function getRecentOffers(userId, n = 5) { + if (!userId) return []; + try { + const snap = await offersRef() + .where('userId', '==', userId) + .orderBy('createdAt', 'desc') + .limit(n) + .get(); + + return snap.docs.map((d) => toPublicOffer({ id: d.id, ...d.data() })); + } catch (err) { + logger.error(`offerService.getRecentOffers error (userId=${userId}): ${err.message}`); + throw err; + } +} + +/** + * Count offers for a user, optionally filtered by status. + * + * @param {string} userId - Firestore user document ID + * @param {string} [status] - Optional status filter + * @returns {Promise} + */ +async function countOffersByUser(userId, status) { + if (!userId) return 0; + try { + let query = offersRef().where('userId', '==', userId); + if (status && OFFER_STATUSES.includes(status)) { + query = query.where('status', '==', status); + } + const snap = await query.get(); + return snap.size; + } catch (err) { + logger.error(`offerService.countOffersByUser error (userId=${userId}): ${err.message}`); + throw err; } +} - const offerData = offerDoc.data(); +/** + * Compute aggregate stats for a user's offers. + * + * @param {string} userId - Firestore user document ID + * @returns {Promise<{ total: number, pending: number, analyzed: number, error: number, savingsTotal: number }>} + */ +async function getOfferStats(userId) { + if (!userId) { + return { total: 0, pending: 0, analyzed: 0, error: 0, savingsTotal: 0 }; + } + try { + const snap = await offersRef().where('userId', '==', userId).get(); + let pending = 0; + let analyzed = 0; + let error = 0; + let savingsTotal = 0; - // Ownership check - if (offerData.userId !== userId) { - logger.warn('Offer ownership mismatch', { - offerId, - requestingUser: userId, - ownerUser: offerData.userId, + snap.docs.forEach((d) => { + const data = d.data(); + if (data.status === 'pending') pending++; + if (data.status === 'analyzed') analyzed++; + if (data.status === 'error') error++; + if (data.analysis && typeof data.analysis.savings === 'number') { + savingsTotal += data.analysis.savings; + } }); - throw new ForbiddenError('You do not have permission to access this offer'); + + return { total: snap.size, pending, analyzed, error, savingsTotal }; + } catch (err) { + logger.error(`offerService.getOfferStats error (userId=${userId}): ${err.message}`); + throw err; + } +} + +// ── Write operations ────────────────────────────────────────────────────────── + +/** + * Create a new offer document in Firestore. + * + * @param {string} userId - Firestore user document ID + * @param {Object} originalFile - { url: string, mimetype: string } + * @param {string} [bankName] - Optional bank name hint + * @returns {Promise} The created offer document + */ +async function createOffer(userId, originalFile, bankName = '') { + if (!userId) throw new Error('userId is required for createOffer'); + if (!originalFile || !originalFile.url) { + throw new Error('originalFile.url is required for createOffer'); } - return { id: offerId, ...offerData }; + const offerData = buildOfferData(userId, originalFile, bankName); + + try { + const docRef = offersRef().doc(); + const docWithId = { id: docRef.id, ...offerData }; + await docRef.set(docWithId); + logger.info(`offerService.createOffer: created offer ${docRef.id} for user ${userId}`); + return toPublicOffer(docWithId); + } catch (err) { + logger.error(`offerService.createOffer error (userId=${userId}): ${err.message}`); + throw err; + } } /** - * Update the analysis.enhanced field of an offer document. + * Update arbitrary fields on an offer document. * - * @param {string} offerId - Firestore document ID. - * @param {object} enhancedReport - The enhanced report data to store. + * Always sets `updatedAt` to the current ISO timestamp. + * + * @param {string} offerId - Firestore document ID + * @param {Object} updates - Fields to update + * @returns {Promise} Updated offer document + */ +async function updateOffer(offerId, updates) { + if (!offerId) throw new Error('offerId is required for updateOffer'); + + const now = new Date().toISOString(); + const safeUpdates = { ...updates, updatedAt: now }; + + // Prevent overwriting immutable fields + delete safeUpdates.id; + delete safeUpdates.userId; + delete safeUpdates.createdAt; + + try { + await offersRef().doc(offerId).update(safeUpdates); + const updated = await findById(offerId); + return toPublicOffer(updated); + } catch (err) { + logger.error(`offerService.updateOffer error (id=${offerId}): ${err.message}`); + throw err; + } +} + +/** + * Update the status of an offer. + * + * @param {string} offerId - Firestore document ID + * @param {string} status - New status ('pending'|'analyzed'|'error') + * @returns {Promise} Updated offer document + */ +async function updateOfferStatus(offerId, status) { + if (!OFFER_STATUSES.includes(status)) { + throw new Error(`Invalid offer status: ${status}. Must be one of: ${OFFER_STATUSES.join(', ')}`); + } + return updateOffer(offerId, { status }); +} + +/** + * Save AI-extracted data and analysis results to an offer document. + * Sets status to 'analyzed' on success. + * + * @param {string} offerId - Firestore document ID + * @param {Object} extractedData - { bank, amount, rate, term } + * @param {Object} analysis - { recommendedRate, savings, aiReasoning } + * @returns {Promise} Updated offer document + */ +async function saveAnalysisResults(offerId, extractedData, analysis) { + if (!offerId) throw new Error('offerId is required for saveAnalysisResults'); + + const updates = { + extractedData: { + bank: extractedData.bank || '', + amount: extractedData.amount ?? null, + rate: extractedData.rate ?? null, + term: extractedData.term ?? null, + }, + analysis: { + recommendedRate: analysis.recommendedRate ?? null, + savings: analysis.savings ?? null, + aiReasoning: analysis.aiReasoning || '', + }, + status: 'analyzed', + }; + + try { + const updated = await updateOffer(offerId, updates); + logger.info(`offerService.saveAnalysisResults: saved analysis for offer ${offerId}`); + return updated; + } catch (err) { + logger.error(`offerService.saveAnalysisResults error (id=${offerId}): ${err.message}`); + throw err; + } +} + +/** + * Mark an offer as errored (e.g., AI analysis failed). + * + * @param {string} offerId - Firestore document ID + * @returns {Promise} Updated offer document + */ +async function markOfferError(offerId) { + return updateOfferStatus(offerId, 'error'); +} + +/** + * Delete an offer document from Firestore. + * Optionally deletes the associated Cloudinary file. + * + * @param {string} offerId - Firestore document ID + * @param {string} userId - Must match offer.userId (ownership check) + * @param {boolean} [deleteFile=true] - Whether to delete the Cloudinary file * @returns {Promise} */ +async function deleteOffer(offerId, userId, deleteFile = true) { + if (!offerId) throw new Error('offerId is required for deleteOffer'); + if (!userId) throw new Error('userId is required for deleteOffer'); + + const offer = await findByIdAndUserId(offerId, userId); + if (!offer) { + const err = new Error('Offer not found or access denied'); + err.statusCode = 404; + throw err; + } + + try { + // Attempt to delete the Cloudinary file (non-fatal if it fails) + if (deleteFile && offer.originalFile && offer.originalFile.url) { + try { + // Extract public_id from Cloudinary URL + // URL format: https://res.cloudinary.com//raw/upload// + const urlParts = offer.originalFile.url.split('/'); + const uploadIndex = urlParts.indexOf('upload'); + if (uploadIndex !== -1) { + // Skip version segment (v1234567890) if present + let publicIdParts = urlParts.slice(uploadIndex + 1); + if (publicIdParts[0] && /^v\d+$/.test(publicIdParts[0])) { + publicIdParts = publicIdParts.slice(1); + } + const publicId = publicIdParts.join('/').replace(/\.[^/.]+$/, ''); + if (publicId) { + await cloudinary.uploader.destroy(publicId, { resource_type: 'raw' }); + logger.info(`offerService.deleteOffer: deleted Cloudinary file ${publicId}`); + } + } + } catch (cloudErr) { + logger.warn(`offerService.deleteOffer: Cloudinary delete failed for offer ${offerId}: ${cloudErr.message}`); + } + } + + await offersRef().doc(offerId).delete(); + logger.info(`offerService.deleteOffer: deleted offer ${offerId} for user ${userId}`); + } catch (err) { + logger.error(`offerService.deleteOffer error (id=${offerId}): ${err.message}`); + throw err; + } +} + +// ── Enhanced Analysis ───────────────────────────────────────────────────────── + +/** + * Store the enhanced AI report in `offer.analysis.enhanced` using a + * Firestore transaction with an idempotent "if not exists" guard. + * + * If `analysis.enhanced` already exists on the document, the write is + * skipped and the existing report is returned instead. This prevents: + * - Concurrent requests from overwriting a completed report. + * - Accidental re-generation charges (AI tokens, Stripe). + * + * @param {string} offerId - Firestore document ID of the offer. + * @param {object} enhancedReport - The enhanced report data to store. + * @returns {Promise<{ stored: boolean, report: object }>} + * stored: true → report was written to Firestore. + * stored: false → report already existed; existing report returned. + * @throws {Error} If offerId is missing or the Firestore transaction fails. + */ async function updateEnhancedAnalysis(offerId, enhancedReport) { - const db = getDb(); - await db.collection(COLLECTIONS.OFFERS).doc(offerId).update({ - 'analysis.enhanced': enhancedReport, - updatedAt: new Date().toISOString(), + if (!offerId) throw new Error('offerId is required for updateEnhancedAnalysis'); + + const offerRef = offersRef().doc(offerId); + + try { + let stored = false; + let resultReport = enhancedReport; + + await db.runTransaction(async (transaction) => { + const offerSnap = await transaction.get(offerRef); + + if (!offerSnap.exists) { + throw new Error(`Offer '${offerId}' not found during enhanced analysis storage`); + } + + const offerData = offerSnap.data(); + + // Idempotency guard: skip write if enhanced report already exists + if (offerData.analysis && offerData.analysis.enhanced) { + logger.info('Enhanced analysis already exists, skipping write (idempotent)', { offerId }); + resultReport = offerData.analysis.enhanced; + stored = false; + return; // abort write, keep existing + } + + // Write the enhanced report + transaction.update(offerRef, { + 'analysis.enhanced': enhancedReport, + updatedAt: new Date().toISOString(), + }); + + stored = true; + }); + + if (stored) { + logger.info('Enhanced analysis stored successfully', { offerId }); + } + + return { stored, report: resultReport }; + } catch (err) { + logger.error(`offerService.updateEnhancedAnalysis error (id=${offerId}): ${err.message}`); + throw err; + } +} + +// ── Upload helper ───────────────────────────────────────────────────────────── + +/** + * Upload a file buffer to Cloudinary and return the result. + * + * Uses a stream-based upload so the buffer is never written to disk. + * + * @param {Buffer} buffer - File buffer (from multer memoryStorage) + * @param {string} mimetype - MIME type of the file + * @returns {Promise<{ url: string, publicId: string }>} + */ +async function uploadFileToCloudinary(buffer, mimetype) { + if (!buffer) throw new Error('buffer is required for uploadFileToCloudinary'); + + return new Promise((resolve, reject) => { + const resourceType = mimetype === 'application/pdf' ? 'raw' : 'image'; + + const uploadStream = cloudinary.uploader.upload_stream( + { + folder: 'morty/offers', + resource_type: resourceType, + }, + (error, result) => { + if (error) { + logger.error(`offerService.uploadFileToCloudinary: Cloudinary error: ${error.message}`); + return reject(new Error(`Cloudinary upload failed: ${error.message}`)); + } + resolve({ + url: result.secure_url, + publicId: result.public_id, + }); + } + ); + + uploadStream.end(buffer); }); - logger.info('Enhanced analysis stored', { offerId }); } -module.exports = { findByIdAndUserId, updateEnhancedAnalysis }; +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // Constants + OFFER_STATUSES, + // Read + findById, + findByIdAndUserId, + listOffersByUser, + getRecentOffers, + countOffersByUser, + getOfferStats, + // Write + createOffer, + updateOffer, + updateOfferStatus, + saveAnalysisResults, + markOfferError, + deleteOffer, + // Enhanced analysis (paid feature) + updateEnhancedAnalysis, + // Upload + uploadFileToCloudinary, + // Internal helpers (exported for testing) + buildOfferData, + toPublicOffer, + snapToDoc, +}; From 52895785936370423e2b88d93ca23a1a156d72bc Mon Sep 17 00:00:00 2001 From: Tambeej <49399681+Tambeej@users.noreply.github.com> Date: Thu, 7 May 2026 14:34:44 +0300 Subject: [PATCH 5/5] feat: return full enhanced report data in response (task 5) - Handle null return from findByIdAndUserId with proper NotFoundError/ForbiddenError - Ensure full enhanced report data is returned with all required fields - Add offer existence check before ownership check for correct error codes - Validate enhanced report completeness before returning to client - Return 201 for newly generated reports, 200 for cached reports - Include all fields: tricks, negotiationScript, insights, comparison, generatedAt, generatedBy, processingTimeMs in response data --- src/controllers/analysisController.js | 152 ++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 8 deletions(-) diff --git a/src/controllers/analysisController.js b/src/controllers/analysisController.js index e90bb13..cdceb53 100644 --- a/src/controllers/analysisController.js +++ b/src/controllers/analysisController.js @@ -4,6 +4,7 @@ const offerService = require('../services/offerService'); const portfolioService = require('../services/portfolioService'); const reportService = require('../services/reportService'); const { sendSuccess } = require('../utils/response'); +const { NotFoundError, ForbiddenError } = require('../utils/errors'); const logger = require('../utils/logger'); /** @@ -20,6 +21,44 @@ const logger = require('../utils/logger'); * 4. Generate report: reportService.generateEnhancedReport(offerId, userId, offer, portfolio) * 5. Respond with { success: true, data: enhancedReport } * + * Response shape (201 for new, 200 for cached): + * { + * success: true, + * message: string, + * data: { + * tricks: Array<{ + * nameHe: string, + * nameEn: string, + * descriptionHe: string, + * descriptionEn: string, + * applicability: 'high'|'medium'|'low', + * riskLevel: 'low'|'medium'|'high', + * potentialSavings: number|null + * }>, + * negotiationScript: string, + * insights: Array<{ + * titleHe: string, + * titleEn: string, + * bodyHe: string, + * bodyEn: string, + * icon: string + * }>, + * comparison: { + * rateDelta: number|null, + * monthlySaving: number|null, + * totalSaving: number|null, + * loanAmount: number, + * termYears: number, + * bankRate: number|null, + * portfolioRate: number|null, + * trackComparison: Array + * }, + * generatedAt: string (ISO 8601), + * generatedBy: 'ai'|'rule-based-fallback', + * processingTimeMs: number + * } + * } + * * @param {import('express').Request} req * @param {import('express').Response} res * @param {import('express').NextFunction} next @@ -31,19 +70,48 @@ async function generateEnhancedReport(req, res, next) { logger.info('Enhanced report requested', { offerId, userId }); - // 1. Verify offer exists and belongs to the user + // 1. Verify offer exists and belongs to the user. + // findByIdAndUserId returns null when: + // a) The offer document does not exist in Firestore. + // b) The offer exists but belongs to a different user. + // We perform a two-step check to return the correct HTTP status code. const offer = await offerService.findByIdAndUserId(offerId, userId); - // 2. Return cached report if it already exists (idempotent) + if (!offer) { + // Distinguish between "not found" and "forbidden" by checking existence + // without the userId filter. This avoids leaking existence information + // to unauthorised callers while still returning the correct status code + // for the authenticated owner. + const existsForAnyUser = await offerService.findById(offerId); + + if (!existsForAnyUser) { + throw new NotFoundError(`Offer with ID '${offerId}' not found`); + } + + // Offer exists but belongs to a different user + throw new ForbiddenError('You do not have permission to access this offer'); + } + + // 2. Return cached report if it already exists (idempotent). + // This prevents duplicate AI calls and ensures consistent results. if (offer.analysis && offer.analysis.enhanced) { logger.info('Returning cached enhanced report', { offerId, userId }); - return sendSuccess(res, offer.analysis.enhanced, 200, 'Enhanced report retrieved from cache'); + + const cachedReport = offer.analysis.enhanced; + + return sendSuccess( + res, + buildResponseData(cachedReport), + 200, + 'Enhanced report retrieved from cache' + ); } - // 3. Fetch user's latest portfolio + // 3. Fetch user's latest portfolio (null is acceptable — fallback handles it). const portfolio = await portfolioService.getUserPortfolio(userId); - // 4. Generate the enhanced report (AI + fallback) + // 4. Generate the enhanced report (AI + fallback). + // reportService stores the result in offer.analysis.enhanced via Firestore. const enhancedReport = await reportService.generateEnhancedReport( offerId, userId, @@ -51,7 +119,7 @@ async function generateEnhancedReport(req, res, next) { portfolio ); - // 5. Respond with the report + // 5. Return the full enhanced report data. logger.info('Enhanced report generated and returned', { offerId, userId, @@ -59,10 +127,78 @@ async function generateEnhancedReport(req, res, next) { processingTimeMs: enhancedReport.processingTimeMs, }); - return sendSuccess(res, enhancedReport, 201, 'Enhanced report generated successfully'); + return sendSuccess( + res, + buildResponseData(enhancedReport), + 201, + 'Enhanced report generated successfully' + ); } catch (error) { next(error); } } -module.exports = { generateEnhancedReport }; +/** + * Build the standardised response data object from an enhanced report. + * + * Ensures all required fields are present and correctly typed before + * sending to the client. This acts as a final sanitisation layer + * regardless of whether the report came from AI, fallback, or cache. + * + * @param {object} report - Raw enhanced report from reportService or Firestore. + * @returns {object} Sanitised response data with all required fields. + */ +function buildResponseData(report) { + return { + // Mortgage tricks (3-5 strategies, always includes Enticement Track) + tricks: Array.isArray(report.tricks) ? report.tricks : [], + + // Word-for-word Hebrew negotiation script + negotiationScript: typeof report.negotiationScript === 'string' + ? report.negotiationScript + : '', + + // Strategic insights explaining the WHY behind recommendations + insights: Array.isArray(report.insights) ? report.insights : [], + + // Rate/payment comparison between bank offer and user's portfolio model + comparison: report.comparison && typeof report.comparison === 'object' + ? { + rateDelta: report.comparison.rateDelta ?? null, + monthlySaving: report.comparison.monthlySaving ?? null, + totalSaving: report.comparison.totalSaving ?? null, + loanAmount: report.comparison.loanAmount ?? 0, + termYears: report.comparison.termYears ?? 30, + bankRate: report.comparison.bankRate ?? null, + portfolioRate: report.comparison.portfolioRate ?? null, + trackComparison: Array.isArray(report.comparison.trackComparison) + ? report.comparison.trackComparison + : [], + } + : { + rateDelta: null, + monthlySaving: null, + totalSaving: null, + loanAmount: 0, + termYears: 30, + bankRate: null, + portfolioRate: null, + trackComparison: [], + }, + + // Metadata + generatedAt: typeof report.generatedAt === 'string' + ? report.generatedAt + : new Date().toISOString(), + + generatedBy: typeof report.generatedBy === 'string' + ? report.generatedBy + : 'unknown', + + processingTimeMs: typeof report.processingTimeMs === 'number' + ? report.processingTimeMs + : 0, + }; +} + +module.exports = { generateEnhancedReport, buildResponseData };