From 29c50f4ebfc5f9e1dae17b399cc14b3c12901230 Mon Sep 17 00:00:00 2001 From: Ishan Raj Singh Date: Mon, 19 Jan 2026 21:16:45 +0530 Subject: [PATCH 1/7] Create read only gmail o auth --- .../src/app/dashboard/SummaryWidget.tsx | 187 ---------- .../src/app/dashboard/SummaryWidgets.tsx | 157 ++++++++ .../src/app/dashboard/UpcomingRenewals.tsx | 168 +++++---- .../client/src/app/dashboard/page.tsx | 335 ++++++++++++------ .../ishanrajsingh/client/src/app/page.tsx | 326 +++++++++++++++-- .../client/src/app/subscriptions/page.tsx | 98 +++-- .../ishanrajsingh/server/package.json | 5 +- contributors/ishanrajsingh/server/src/app.js | 2 + .../server/src/config/googleOAuth.js | 14 + .../src/controllers/gmailAuthController.js | 52 +++ .../server/src/models/GmailToken.js | 10 + .../server/src/routes/gmailAuth.routes.js | 12 + 12 files changed, 933 insertions(+), 433 deletions(-) delete mode 100644 contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidget.tsx create mode 100644 contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidgets.tsx create mode 100644 contributors/ishanrajsingh/server/src/config/googleOAuth.js create mode 100644 contributors/ishanrajsingh/server/src/controllers/gmailAuthController.js create mode 100644 contributors/ishanrajsingh/server/src/models/GmailToken.js create mode 100644 contributors/ishanrajsingh/server/src/routes/gmailAuth.routes.js diff --git a/contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidget.tsx b/contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidget.tsx deleted file mode 100644 index 7c5178c4..00000000 --- a/contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidget.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Card } from '@/components/ui/card'; -import { TrendingUp, Calendar, CreditCard, Zap } from 'lucide-react'; -import { useAuth } from '@clerk/nextjs'; - -interface Subscription { - amount: number; - billingCycle: 'monthly' | 'yearly' | string; - status: string; - isTrial?: boolean; -} - -type Metrics = { - monthlySpend: number; - yearlySpend: number; - activeCount: number; - trialCount: number; -}; - -const API_URL = - process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'; - -async function fetchMetrics(token?: string | null): Promise { - const headers: Record = {}; - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(`${API_URL}/api/subscriptions`, { headers }); - const json = await res.json(); - - const list: Subscription[] = Array.isArray(json.data) - ? json.data - : []; - const meta = json.meta || {}; - - let active = 0; - let trials = 0; - - list.forEach(s => { - if (s.status === 'active') active++; - if (s.isTrial) trials++; - }); - - return { - monthlySpend: - typeof meta.monthlySpend === 'number' ? meta.monthlySpend : 0, - yearlySpend: - typeof meta.yearlySpend === 'number' ? meta.yearlySpend : 0, - activeCount: active, - trialCount: trials, - }; -} - -/* ----------------------------- - Count-up animation (UNCHANGED) ------------------------------- */ -function useCountUp(target: number, duration = 900) { - const [value, setValue] = useState(0); - - useEffect(() => { - let startTime: number | null = null; - - const animate = (t: number) => { - if (!startTime) startTime = t; - const progress = Math.min((t - startTime) / duration, 1); - setValue(Math.floor(target * progress)); - if (progress < 1) requestAnimationFrame(animate); - else setValue(target); - }; - - requestAnimationFrame(animate); - return () => setValue(target); - }, [target, duration]); - - return value; -} - -export default function SummaryWidgets() { - const { getToken } = useAuth(); - const [metrics, setMetrics] = useState(null); - - useEffect(() => { - (async () => { - const token = await getToken?.(); - const data = await fetchMetrics(token); - setMetrics(data); - })(); - }, [getToken]); - - const monthly = useCountUp(metrics?.monthlySpend ?? 0); - const yearly = useCountUp(metrics?.yearlySpend ?? 0); - const active = useCountUp(metrics?.activeCount ?? 0); - const trials = useCountUp(metrics?.trialCount ?? 0); - - const cards = [ - { - label: 'Monthly Spend', - value: `₹${monthly.toLocaleString()}`, - icon: TrendingUp, - primary: true, - gradient: 'from-blue-500/20 via-blue-400/10 to-transparent', - glow: 'shadow-blue-500/30', - }, - { - label: 'Yearly Spend', - value: `₹${yearly.toLocaleString()}`, - icon: Calendar, - gradient: 'from-purple-500/20 via-purple-400/10 to-transparent', - glow: 'shadow-purple-500/20', - }, - { - label: 'Active Subscriptions', - value: active, - icon: CreditCard, - gradient: 'from-emerald-500/20 via-emerald-400/10 to-transparent', - glow: 'shadow-emerald-500/20', - }, - { - label: 'Trials', - value: trials, - icon: Zap, - gradient: 'from-amber-500/20 via-amber-400/10 to-transparent', - glow: 'shadow-amber-500/20', - }, - ]; - - return ( -
- {cards.map(card => { - const Icon = card.icon; - - return ( - - {/* Gradient wash */} -
- - {/* Content */} -
- {/* Top */} -
- - {card.label} - - -
- -
-
- - {/* Value */} - {metrics ? ( - - {card.value} - - ) : ( -
- )} -
- - {/* Hover glow */} -
-
-
- - ); - })} -
- ); -} diff --git a/contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidgets.tsx b/contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidgets.tsx new file mode 100644 index 00000000..1a00132e --- /dev/null +++ b/contributors/ishanrajsingh/client/src/app/dashboard/SummaryWidgets.tsx @@ -0,0 +1,157 @@ +import { Card } from '@/components/ui/card'; +import { useAuth } from '@clerk/nextjs'; +import { Calendar, CreditCard, TrendingUp, Zap } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface Subscription { + amount: number; + billingCycle: 'monthly' | 'yearly' | string; + status: string; + isTrial?: boolean; +} + +type Metrics = { + monthlySpend: number; + yearlySpend: number; + activeCount: number; + trialCount: number; +}; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'; + +const fetchMetrics = async ( + token: string | null | undefined +): Promise => { + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${API_BASE_URL}/api/subscriptions`, { headers }); + const data = await res.json(); + + const subs = Array.isArray(data.data) ? data.data : []; + const meta = data.meta || {}; + + const monthlySpend = + typeof meta.monthlySpend === 'number' ? meta.monthlySpend : 0; + const yearlySpend = + typeof meta.yearlySpend === 'number' ? meta.yearlySpend : 0; + + let activeCount = 0; + let trialCount = 0; + + subs.forEach((sub: Subscription) => { + if (sub.status === 'active') activeCount++; + if (sub.isTrial) trialCount++; + }); + + return { monthlySpend, yearlySpend, activeCount, trialCount }; +}; + +// CountUp hook +function useCountUp(target: number, duration = 1000) { + const [value, setValue] = useState(0); + + useEffect(() => { + let startTime: number | null = null; + + function animate(ts: number) { + if (!startTime) startTime = ts; + const progress = Math.min((ts - startTime) / duration, 1); + + setValue(Math.floor(target * progress)); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + setValue(target); + } + } + + requestAnimationFrame(animate); + }, [target, duration]); + + return value; +} + +const SummaryWidgets = () => { + const [metrics, setMetrics] = useState(null); + const { getToken } = useAuth(); + + useEffect(() => { + (async () => { + const token = await getToken?.(); + fetchMetrics(token).then(setMetrics); + })(); + }, [getToken]); + + const animatedMonthly = useCountUp(metrics?.monthlySpend ?? 0); + const animatedYearly = useCountUp(metrics?.yearlySpend ?? 0); + const animatedActive = useCountUp(metrics?.activeCount ?? 0); + const animatedTrial = useCountUp(metrics?.trialCount ?? 0); + + const widgetData = [ + { + label: 'Monthly Spend', + value: metrics ? `₹${animatedMonthly.toLocaleString()}` : null, + icon: , + highlight: true, + bg: 'bg-gradient-to-br from-[#101c2c] to-[#0a0f1a]', + ring: 'ring-2 ring-blue-500/40 shadow-blue-500/20', + }, + { + label: 'Yearly Spend', + value: metrics ? `₹${animatedYearly.toLocaleString()}` : null, + icon: , + highlight: false, + bg: 'bg-gradient-to-br from-[#1a102c] to-[#120a1a]', + ring: 'ring-1 ring-purple-900/30', + }, + { + label: 'Active Subscriptions', + value: metrics ? animatedActive : null, + icon: , + highlight: false, + bg: 'bg-gradient-to-br from-[#102c1a] to-[#0a1a12]', + ring: 'ring-1 ring-green-900/30', + }, + { + label: 'Trials Count', + value: metrics ? animatedTrial : null, + icon: , + highlight: false, + bg: 'bg-gradient-to-br from-[#2c2410] to-[#1a150a]', + ring: 'ring-1 ring-yellow-900/30', + }, + ]; + + return ( +
+ {widgetData.map((w) => ( + +
{w.icon}
+ + + {w.label} + + + {w.value !== null ? ( + + {w.value} + + ) : ( + + )} +
+ ))} +
+ ); +}; + +export default SummaryWidgets; diff --git a/contributors/ishanrajsingh/client/src/app/dashboard/UpcomingRenewals.tsx b/contributors/ishanrajsingh/client/src/app/dashboard/UpcomingRenewals.tsx index f5b29841..31cc0d95 100644 --- a/contributors/ishanrajsingh/client/src/app/dashboard/UpcomingRenewals.tsx +++ b/contributors/ishanrajsingh/client/src/app/dashboard/UpcomingRenewals.tsx @@ -1,96 +1,124 @@ -'use client'; +import { Card } from '@/components/ui/card'; +import { getServiceColors, getServiceIcon } from '@/lib/service-icons'; +import { getDaysUntilRenewal } from '@/lib/utils'; +import { format } from 'date-fns'; +import { AlertTriangle, Calendar } from 'lucide-react'; +import React from 'react'; -import { Subscription } from '@/lib/api'; -import { AlertTriangle, Clock } from 'lucide-react'; - -function daysUntil(dateISO: string) { - const today = new Date(); - const target = new Date(dateISO); - - today.setHours(0, 0, 0, 0); - target.setHours(0, 0, 0, 0); - - return Math.ceil((target.getTime() - today.getTime()) / 86400000); +interface Subscription { + name: string; + renewalDate: string; + amount: number; + billingCycle: string; + isTrial?: boolean; + status: string; } -function formatDate(dateISO: string) { - return new Date(dateISO).toLocaleDateString(undefined, { - day: 'numeric', - month: 'short', - year: 'numeric', - }); +interface UpcomingRenewalsProps { + subscriptions: Subscription[]; } -type Props = { - subscriptions: Subscription[]; +const getUrgency = (days: number) => { + if (days <= 7) return 'urgent'; + if (days <= 30) return 'soon'; + return 'normal'; }; -export default function UpcomingRenewals({ subscriptions }: Props) { +const UpcomingRenewals: React.FC = ({ + subscriptions, +}) => { const upcoming = subscriptions - .filter(s => s.status === 'active') - .map(s => ({ ...s, daysLeft: daysUntil(s.renewalDate) })) - .filter(s => s.daysLeft >= 0 && s.daysLeft <= 30) - .sort((a, b) => a.daysLeft - b.daysLeft); + .filter((sub) => { + const days = getDaysUntilRenewal(sub.renewalDate); + return sub.status === 'active' && days >= 0 && days <= 30; + }) + .sort( + (a, b) => + new Date(a.renewalDate).getTime() - new Date(b.renewalDate).getTime() + ); if (upcoming.length === 0) { return ( -
-

- Upcoming Renewals -

-

- No renewals in the next 30 days 🎉 -

-
+ + + No upcoming renewals in the next 30 days. + + ); } return ( -
-
- -

- Upcoming Renewals -

+
+
+ +

Upcoming Renewals

- -
- {upcoming.map(sub => { - const urgent = sub.daysLeft <= 7; - +
+ {upcoming.map((sub) => { + const days = getDaysUntilRenewal(sub.renewalDate); + const urgency = getUrgency(days); + const icon = getServiceIcon(sub.name); + const colors = getServiceColors(sub.name); return ( -
-
-
- {sub.name} - {urgent && ( - - - Urgent +
+ {icon && ( + + + {icon} - )} -
-
- Renews on {formatDate(sub.renewalDate)} + + )} +
+ + {sub.name} + + + {format(new Date(sub.renewalDate), 'MMM d, yyyy')} •{' '} + {sub.billingCycle} +
- -
- {sub.daysLeft === 0 - ? 'Renews today' - : `Renews in ${sub.daysLeft} days`} +
+ + ₹{sub.amount} + + + {urgency === 'urgent' && ( + + )} + Renews in {days} day{days !== 1 ? 's' : ''} +
-
+ ); })}
-
+
); -} +}; + +export default UpcomingRenewals; diff --git a/contributors/ishanrajsingh/client/src/app/dashboard/page.tsx b/contributors/ishanrajsingh/client/src/app/dashboard/page.tsx index b5b4f078..7172ad0d 100644 --- a/contributors/ishanrajsingh/client/src/app/dashboard/page.tsx +++ b/contributors/ishanrajsingh/client/src/app/dashboard/page.tsx @@ -1,12 +1,16 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { Subscription } from '@/lib/api'; +import { getServiceColors, getServiceIcon } from '@/lib/service-icons'; +import { + cn, + formatCurrency, + formatDate, + getCategoryColor, + getDaysUntilRenewal, + isUrgentRenewal, +} from '@/lib/utils'; import { useAuth } from '@clerk/nextjs'; -import Link from 'next/link'; -import DashboardLayout from '../components/DashboardLayout'; -import SummaryWidgets from './SummaryWidget'; -import UpcomingRenewals from './UpcomingRenewals'; - import { AlertTriangle, ArrowRight, @@ -14,17 +18,11 @@ import { Plus, TrendingUp, } from 'lucide-react'; - -import { Subscription } from '@/lib/api'; -import { - cn, - formatCurrency, - getDaysUntilRenewal, - isUrgentRenewal, - getCategoryColor, - formatDate, -} from '@/lib/utils'; -import { getServiceIcon, getServiceColors } from '@/lib/service-icons'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import DashboardLayout from '../components/DashboardLayout'; +import SummaryWidgets from './SummaryWidgets'; +import UpcomingRenewals from './UpcomingRenewals'; // Mock data for demonstration const mockSubscriptions: Subscription[] = [ @@ -36,7 +34,7 @@ const mockSubscriptions: Subscription[] = [ currency: 'USD', billingCycle: 'monthly', category: 'entertainment', - renewalDate: new Date(Date.now() + 2 * 86400000).toISOString(), + renewalDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), isTrial: false, source: 'manual', status: 'active', @@ -77,7 +75,7 @@ const mockSubscriptions: Subscription[] = [ _id: '4', userId: 'user1', name: 'ChatGPT Plus', - amount: 20, + amount: 20.0, currency: 'USD', billingCycle: 'monthly', category: 'productivity', @@ -93,7 +91,7 @@ const mockSubscriptions: Subscription[] = [ _id: '5', userId: 'user1', name: 'GitHub Copilot', - amount: 10, + amount: 10.0, currency: 'USD', billingCycle: 'monthly', category: 'productivity', @@ -108,7 +106,7 @@ const mockSubscriptions: Subscription[] = [ _id: '6', userId: 'user1', name: 'Notion', - amount: 8, + amount: 8.0, currency: 'USD', billingCycle: 'monthly', category: 'productivity', @@ -121,67 +119,107 @@ const mockSubscriptions: Subscription[] = [ }, ]; -export default function DashboardPage() { +export default function Dashboard() { const { getToken } = useAuth(); - const [subs, setSubs] = useState([]); - const [loading, setLoading] = useState(true); + const [subscriptions, setSubscriptions] = useState([]); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { - const load = async () => { + const fetchData = async () => { try { - await new Promise(r => setTimeout(r, 600)); - setSubs(mockSubscriptions); + const token = await getToken?.(); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/subscriptions`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const data = await res.json(); + setSubscriptions(Array.isArray(data.data) ? data.data : []); + } catch (err) { + console.error('Failed to fetch subscriptions', err); } finally { - setLoading(false); + setIsLoading(false); } }; - load(); + + fetchData(); }, [getToken]); - const activeSubs = subs.filter(s => s.status === 'active'); - const urgent = subs.filter( - s => s.status === 'active' && isUrgentRenewal(s.renewalDate), + // Calculate stats + const activeSubscriptions = subscriptions.filter( + (s) => s.status === 'active' + ); + const monthlyTotal = activeSubscriptions.reduce((sum, sub) => { + if (sub.billingCycle === 'monthly') return sum + sub.amount; + if (sub.billingCycle === 'yearly') return sum + sub.amount / 12; + if (sub.billingCycle === 'weekly') return sum + sub.amount * 4.33; + return sum + sub.amount; + }, 0); + const urgentRenewals = subscriptions.filter( + (s) => s.status === 'active' && isUrgentRenewal(s.renewalDate) + ); + const trialsEnding = subscriptions.filter( + (s) => s.isTrial && s.trialEndsAt && getDaysUntilRenewal(s.trialEndsAt) <= 7 ); - const upcoming = [...activeSubs] + // Get recent subscriptions sorted by renewal date + const recentSubscriptions = [...subscriptions] + .filter((s) => s.status === 'active') .sort( (a, b) => - new Date(a.renewalDate).getTime() - - new Date(b.renewalDate).getTime(), + new Date(a.renewalDate).getTime() - new Date(b.renewalDate).getTime() ) .slice(0, 5); - if (loading) { + if (isLoading) { return ( - -
- + +
+
+ +

Loading dashboard...

+
); } return ( - - {/* Welcome */} -
-
+ + {/* Welcome Banner */} +
+
-

Welcome back 👋

+

+ Welcome back! 👋 +

- {urgent.length > 0 ? ( + You have{' '} + {urgentRenewals.length > 0 ? ( - {urgent.length} renewal{urgent.length > 1 && 's'} soon + {urgentRenewals.length} upcoming renewal + {urgentRenewals.length > 1 ? 's' : ''} ) : ( - 'No urgent renewals' - )} + 'no urgent renewals' + )}{' '} + in the next 3 days.

- Add Subscription @@ -189,73 +227,83 @@ export default function DashboardPage() {
- {/* Summary Widgets */} + {/* Dashboard Summary Widgets */} - {/* 🔥 ADDED: Issue 15 Upcoming Renewals Section */} - + {/* Upcoming Renewals Section */} + - {/* Existing Main Grid (UNCHANGED) */} -
-
-
+ {/* Main Content Grid */} +
+ {/* Recent Subscriptions */} +
+

Upcoming Renewals

View all
- {upcoming.length === 0 ? ( -

- No active subscriptions -

+ {recentSubscriptions.length === 0 ? ( +
+

No active subscriptions

+ + Add your first subscription + +
) : (
- {upcoming.map(sub => { - const days = getDaysUntilRenewal(sub.renewalDate); - const urgentFlag = isUrgentRenewal(sub.renewalDate); + {recentSubscriptions.map((sub) => { + const daysUntil = getDaysUntilRenewal(sub.renewalDate); + const isUrgent = isUrgentRenewal(sub.renewalDate); const categoryColors = getCategoryColor(sub.category); - const icon = getServiceIcon(sub.name); + const initials = sub.name + .split(' ') + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + + // Get service-specific icon and colors + const serviceIcon = getServiceIcon(sub.name); const serviceColors = getServiceColors(sub.name); + const iconBg = serviceColors?.bg || categoryColors.bg; + const iconText = serviceColors?.text || categoryColors.text; return (
- {icon || - sub.name - .split(' ') - .map(w => w[0]) - .slice(0, 2) - .join('') - .toUpperCase()} + {serviceIcon || initials}
- -
+
- + {sub.name} {sub.isTrial && ( - + Trial )} @@ -264,23 +312,28 @@ export default function DashboardPage() { {sub.category}
- +
+
+ {formatCurrency(sub.amount)} +
+
+ /{sub.billingCycle === 'yearly' ? 'year' : 'mo'} +
+
-
- {urgentFlag && ( - - )} - - {days === 0 +
+ {isUrgent && } + + {daysUntil === 0 ? 'Today' - : days === 1 - ? 'Tomorrow' - : `${days}d`} + : daysUntil === 1 + ? 'Tomorrow' + : `${daysUntil}d`}
@@ -294,25 +347,91 @@ export default function DashboardPage() { )}
+ {/* Quick Actions & Insights */}
-
-

+ {/* Quick Actions */} +
+

Quick Actions

- - -
-
- View Analytics +
+ +
+
-
- Spending insights +
+
Add Subscription
+
+ Track a new service +
-
- + + +
+ +
+
+
View Analytics
+
+ See spending trends +
+
+ +
+
+ + {/* Spending Insight */} +
+
+ +

Spending Insight

+
+

+ Your productivity tools account for{' '} + + {formatCurrency( + subscriptions + .filter( + (s) => + s.category === 'productivity' && s.status === 'active' + ) + .reduce((sum, s) => sum + s.amount, 0) + )} + {' '} + of your monthly spend. +

+
+ {Array.from( + new Set( + subscriptions + .filter((s) => s.status === 'active') + .map((s) => s.category) + ) + ).map((category) => { + const colors = getCategoryColor(category); + const count = subscriptions.filter( + (s) => s.category === category && s.status === 'active' + ).length; + return ( + + {category} ({count}) + + ); + })} +
diff --git a/contributors/ishanrajsingh/client/src/app/page.tsx b/contributors/ishanrajsingh/client/src/app/page.tsx index ee91f08e..29bda5ae 100644 --- a/contributors/ishanrajsingh/client/src/app/page.tsx +++ b/contributors/ishanrajsingh/client/src/app/page.tsx @@ -1,41 +1,303 @@ -import { SignedIn, SignedOut, SignInButton, SignUpButton } from '@clerk/nextjs'; +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; +const INTRO_STORAGE_KEY = 'subsentry_intro_seen'; + export default function Home() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { isLoaded, isSignedIn } = useUser(); + const [showIntro, setShowIntro] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const [videoError, setVideoError] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (!isLoaded) return; + + if (isSignedIn) { + router.replace('/dashboard'); + return; + } + + const forceIntro = searchParams?.get('intro') === '1'; + const seen = window.localStorage.getItem(INTRO_STORAGE_KEY); + if (forceIntro || !seen) { + setShowIntro(true); + } + }, [isLoaded, isSignedIn, router, searchParams]); + + const finishIntro = useCallback(() => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(INTRO_STORAGE_KEY, '1'); + } + setIsExiting(true); + setTimeout(() => { + setShowIntro(false); + }, 300); + }, []); + + const handleSkip = useCallback(() => { + finishIntro(); + }, [finishIntro]); + + useEffect(() => { + if (!showIntro || typeof window === 'undefined') return; + + const controller = new AbortController(); + setIsExiting(false); + setVideoError(false); + + fetch('/fintech-intro.mp4', { method: 'HEAD', signal: controller.signal }) + .then((res) => { + if (!res.ok) { + throw new Error('intro video missing'); + } + }) + .catch(() => { + setVideoError(true); + finishIntro(); + }); + + return () => controller.abort(); + }, [showIntro, finishIntro]); + + if (!isLoaded) { + return null; + } + return ( -
-
-

SubSentry

- -

- Secure subscription management with industry-grade authentication. -

- -
- - - - - - - - - - - - - Go to Dashboard + <> + {showIntro && ( +
+
+ )} + + {!showIntro && !isSignedIn && ( +
+ {/* Gradient Background Effects */} +
+
+
+
+
+ + {/* Grid Pattern Overlay */} +
+ + {/* Navigation */} + + + {/* Hero Section */} +
+ {/* Badge */} +
+
+ Trusted by 10,000+ users worldwide +
+ + {/* Main Heading */} +

+ Never lose track of +
+ + subscriptions again + +

+ + {/* Subheading */} +

+ SubSentry helps you manage all your subscriptions in one place. Get real-time alerts, + track spending, and save money effortlessly. +

+ + {/* CTA Buttons */} + {/* CTA Buttons */} +
+ + + + + + +
+ + {/* Micro-copy */} +
+
+ + + + No credit card required +
+
+ + + + Free forever plan +
+
+ + + + Cancel anytime +
+
+ + {/* Feature Cards */} +
+ + + + } + title="Smart Alerts" + description="Get notified before every renewal. Never be surprised by unexpected charges again." + gradient="from-blue-500 to-cyan-500" + /> + + + + } + title="Analytics Dashboard" + description="Visualize your spending patterns and find opportunities to save money." + gradient="from-purple-500 to-pink-500" + /> + + + + } + title="Bank-grade Security" + description="256-bit encryption and SOC 2 compliance keep your data safe and private." + gradient="from-green-500 to-emerald-500" + /> +
+
+ + {/* Footer */} +
+
+ Privacy Policy + Terms of Service + Contact +
+

+ © 2026 SubSentry. All rights reserved. +

+
-
+ )} + + ); +} + +function FeatureCard({ + icon, + title, + description, + gradient, +}: { + icon: React.ReactNode; + title: string; + description: string; + gradient: string; +}) { + return ( +
+
+ {icon} +
+

{title}

+

{description}

+ {/* Hover glow effect */} +
); } diff --git a/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx b/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx index 6a858582..75371d76 100644 --- a/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx +++ b/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx @@ -1,30 +1,35 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { ShimmerButton } from '@/components/ui/aceternity'; +import { Button } from '@/components/ui/button'; +import { + Subscription, + deleteSubscription, + getSubscriptions, + updateSubscription, +} from '@/lib/api'; +import { cn } from '@/lib/utils'; import { useAuth } from '@clerk/nextjs'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Loader2, RefreshCw } from 'lucide-react'; import Link from 'next/link'; -import { Plus, Loader2, RefreshCw } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { useEffect, useMemo, useState } from 'react'; import DashboardLayout from '../components/DashboardLayout'; import { - SubscriptionCard, - FilterBar, - SortDropdown, - ViewToggle, EmptyState, - QuickStats, - FilterStatus, + FilterBar, FilterBillingCycle, FilterCategory, + FilterStatus, + QuickStats, + RemoveSubscriptionDialog, + SortDropdown, SortField, SortOrder, + SubscriptionCard, UpdateSubscriptionModal, - RemoveSubscriptionDialog, + ViewToggle, } from '../components/subscriptions'; -import { Subscription, getSubscriptions, updateSubscription, deleteSubscription } from '@/lib/api'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { ShimmerButton } from '@/components/ui/aceternity'; export default function SubscriptionsPage() { const { getToken } = useAuth(); @@ -37,7 +42,8 @@ export default function SubscriptionsPage() { // Filter state const [statusFilter, setStatusFilter] = useState('all'); - const [billingCycleFilter, setBillingCycleFilter] = useState('all'); + const [billingCycleFilter, setBillingCycleFilter] = + useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); // Sort state @@ -45,8 +51,10 @@ export default function SubscriptionsPage() { const [sortOrder, setSortOrder] = useState('asc'); // Edit/Delete modal state - const [editingSubscription, setEditingSubscription] = useState(null); - const [deletingSubscription, setDeletingSubscription] = useState(null); + const [editingSubscription, setEditingSubscription] = + useState(null); + const [deletingSubscription, setDeletingSubscription] = + useState(null); // Fetch subscriptions from real API useEffect(() => { @@ -62,7 +70,9 @@ export default function SubscriptionsPage() { const data = await getSubscriptions(token); setSubscriptions(data.data || []); } catch (err) { - setError('Failed to load subscriptions. Make sure the server is running.'); + setError( + 'Failed to load subscriptions. Make sure the server is running.' + ); console.error(err); } finally { setIsLoading(false); @@ -85,13 +95,13 @@ export default function SubscriptionsPage() { // Apply filters if (statusFilter !== 'all') { - result = result.filter(s => s.status === statusFilter); + result = result.filter((s) => s.status === statusFilter); } if (billingCycleFilter !== 'all') { - result = result.filter(s => s.billingCycle === billingCycleFilter); + result = result.filter((s) => s.billingCycle === billingCycleFilter); } if (categoryFilter !== 'all') { - result = result.filter(s => s.category === categoryFilter); + result = result.filter((s) => s.category === categoryFilter); } // Apply sorting @@ -99,7 +109,9 @@ export default function SubscriptionsPage() { let comparison = 0; switch (sortField) { case 'renewalDate': - comparison = new Date(a.renewalDate).getTime() - new Date(b.renewalDate).getTime(); + comparison = + new Date(a.renewalDate).getTime() - + new Date(b.renewalDate).getTime(); break; case 'amount': comparison = a.amount - b.amount; @@ -108,14 +120,22 @@ export default function SubscriptionsPage() { comparison = a.name.localeCompare(b.name); break; case 'createdAt': - comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + comparison = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); break; } return sortOrder === 'asc' ? comparison : -comparison; }); return result; - }, [subscriptions, statusFilter, billingCycleFilter, categoryFilter, sortField, sortOrder]); + }, [ + subscriptions, + statusFilter, + billingCycleFilter, + categoryFilter, + sortField, + sortOrder, + ]); const clearFilters = () => { setStatusFilter('all'); @@ -142,7 +162,10 @@ export default function SubscriptionsPage() { } }; - const handleEditSubscription = async (id: string, data: Partial) => { + const handleEditSubscription = async ( + id: string, + data: Partial + ) => { const token = await getToken(); if (!token) { throw new Error('Authentication required'); @@ -165,7 +188,10 @@ export default function SubscriptionsPage() { }; return ( - + {/* Quick Stats */} {!isLoading && subscriptions.length > 0 && (
@@ -191,7 +217,9 @@ export default function SubscriptionsPage() { sortField={sortField} sortOrder={sortOrder} onSortChange={setSortField} - onOrderToggle={() => setSortOrder(o => o === 'asc' ? 'desc' : 'asc')} + onOrderToggle={() => + setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc')) + } /> @@ -206,10 +234,9 @@ export default function SubscriptionsPage() { - - - - Add New + + + + Add New
@@ -240,7 +267,9 @@ export default function SubscriptionsPage() { ) : (
-

No subscriptions match your filters

+

+ No subscriptions match your filters +

+ + + )} + + {state === 'loading' && ( + + )} + + {(state === 'success' || + state === 'error' || + state === 'stopped') && ( + + )} +
+ + + )} + + ); +} diff --git a/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx b/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx index 75371d76..a87b962a 100644 --- a/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx +++ b/contributors/ishanrajsingh/client/src/app/subscriptions/page.tsx @@ -30,6 +30,8 @@ import { UpdateSubscriptionModal, ViewToggle, } from '../components/subscriptions'; +import { useRef } from 'react'; +import EmailImportModal from '../components/subscriptions/EmailImportModal'; export default function SubscriptionsPage() { const { getToken } = useAuth(); @@ -56,6 +58,15 @@ export default function SubscriptionsPage() { const [deletingSubscription, setDeletingSubscription] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [importSuccess, setImportSuccess] = useState(null); + const [importError, setImportError] = useState(null); + const [showImportModal, setShowImportModal] = useState(false); + const [importState, setImportState] = useState< + 'explain' | 'loading' | 'success' | 'error' | 'stopped' + >('explain'); + const abortControllerRef = useRef(null); + // Fetch subscriptions from real API useEffect(() => { const fetchSubscriptions = async () => { @@ -162,6 +173,94 @@ export default function SubscriptionsPage() { } }; + const openEmailImportModal = () => { + setShowImportModal(true); + setImportState('explain'); + setImportError(null); + setImportSuccess(null); + }; + + const handleEmailImport = async () => { + setImportState('loading'); + setImportError(null); + setImportSuccess(null); + + const start = Date.now(); + + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + const token = await getToken(); + if (!token) throw new Error('Authentication required'); + + const endpoint = `${process.env.NEXT_PUBLIC_API_URL}/api/subscriptions/email/import`; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + signal: controller.signal, + }); + + const rawText = await res.text(); + + let data: any; + try { + data = JSON.parse(rawText); + } catch { + throw new Error('Invalid server response'); + } + + if (!res.ok) { + throw new Error(data?.message || 'Email import failed'); + } + + const elapsed = Date.now() - start; + if (elapsed < 3000) { + await new Promise((r) => setTimeout(r, 3000 - elapsed)); + } + + const inserted = Number(data.inserted || 0); + const skipped = Number(data.skipped || 0); + + setImportSuccess( + `Imported ${inserted} subscription${inserted !== 1 ? 's' : ''}` + + (skipped ? `, skipped ${skipped}` : '') + ); + + setImportState('success'); + + await handleRefresh(); + } catch (err: any) { + const elapsed = Date.now() - start; + if (elapsed < 3000) { + await new Promise((r) => setTimeout(r, 3000 - elapsed)); + } + + if (err.name === 'AbortError') { + setImportError('Import stopped by user'); + setImportState('stopped'); + return; + } + + setImportError(err.message || 'Failed to import subscriptions'); + setImportState('error'); + } finally { + abortControllerRef.current = null; + } +}; +const handleStopImport = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } +}; + + + const handleEditSubscription = async ( id: string, data: Partial @@ -234,11 +333,35 @@ export default function SubscriptionsPage() { - - - + Add New - - +
+ {/* Import from Email */} + + + {/* Add New */} + + + + Add New + + +
+
@@ -326,6 +449,20 @@ export default function SubscriptionsPage() { }} onRemove={handleDeleteSubscription} /> + { + setShowImportModal(false); + setImportState('explain'); + }} + /> + + ); } From eab32ea91e026186e5af05ec696db8e9d3f391ca Mon Sep 17 00:00:00 2001 From: Ishan Raj Singh Date: Tue, 20 Jan 2026 22:35:20 +0530 Subject: [PATCH 7/7] Update backend --- contributors/ishanrajsingh/server/src/app.js | 15 +- .../server/src/config/googleOAuth.js | 124 ++++++- .../src/controllers/gmailAuthController.js | 320 ++++++++++++++++-- .../src/controllers/gmailFetch.controller.js | 83 ----- .../src/controllers/gmailParse.controller.js | 37 -- .../server/src/models/GmailToken.js | 50 +++ .../server/src/routes/gmailAuth.routes.js | 27 +- .../server/src/routes/gmailFetch.routes.js | 9 - .../server/src/routes/gmailParse.routes.js | 9 - .../server/src/services/fetchEmails.js | 88 +++++ .../server/src/services/parseEmails.js | 180 ++++++++++ .../server/src/services/subscriptionSave.js | 185 ++++++++++ .../server/src/utils/emailParser.util.js | 47 --- .../server/src/utils/gmailToken.util.js | 19 -- 14 files changed, 924 insertions(+), 269 deletions(-) delete mode 100644 contributors/ishanrajsingh/server/src/controllers/gmailFetch.controller.js delete mode 100644 contributors/ishanrajsingh/server/src/controllers/gmailParse.controller.js delete mode 100644 contributors/ishanrajsingh/server/src/routes/gmailFetch.routes.js delete mode 100644 contributors/ishanrajsingh/server/src/routes/gmailParse.routes.js create mode 100644 contributors/ishanrajsingh/server/src/services/fetchEmails.js create mode 100644 contributors/ishanrajsingh/server/src/services/parseEmails.js create mode 100644 contributors/ishanrajsingh/server/src/services/subscriptionSave.js delete mode 100644 contributors/ishanrajsingh/server/src/utils/emailParser.util.js delete mode 100644 contributors/ishanrajsingh/server/src/utils/gmailToken.util.js diff --git a/contributors/ishanrajsingh/server/src/app.js b/contributors/ishanrajsingh/server/src/app.js index 5f7f556a..44af8120 100644 --- a/contributors/ishanrajsingh/server/src/app.js +++ b/contributors/ishanrajsingh/server/src/app.js @@ -1,24 +1,21 @@ import cors from 'cors'; import express from 'express'; -import subscriptionRoutes from './routes/subscription.routes.js'; + import attachUser from './middleware/attachUser.js'; -import gmailAuthRoutes from "./routes/gmailAuth.routes.js"; -import gmailFetchRoutes from "./routes/gmailFetch.routes.js"; -import gmailParseRoutes from "./routes/gmailParse.routes.js"; +import subscriptionRoutes from './routes/subscription.routes.js'; +import gmailRoutes from './routes/gmailAuth.routes.js'; const app = express(); app.use(cors()); app.use(express.json()); app.use(attachUser); -app.use("/api", gmailAuthRoutes); -app.use("/api", gmailFetchRoutes); -app.use("/api", gmailParseRoutes); -app.get('/', (_, res) => { +app.get('/', (_req, res) => { res.send('SubSentry API running'); }); +app.use('/api/gmail', gmailRoutes); app.use('/api/subscriptions', subscriptionRoutes); -export default app; +export default app; \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/config/googleOAuth.js b/contributors/ishanrajsingh/server/src/config/googleOAuth.js index 64f48af6..27a53b47 100644 --- a/contributors/ishanrajsingh/server/src/config/googleOAuth.js +++ b/contributors/ishanrajsingh/server/src/config/googleOAuth.js @@ -1,14 +1,110 @@ -import { google } from "googleapis"; -import dotenv from "dotenv"; -dotenv.config(); - -export const oauth2Client = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET, - process.env.GOOGLE_REDIRECT_URI -); - -// Only Gmail read-only scope -export const SCOPES = [ - "https://www.googleapis.com/auth/gmail.readonly" -]; +import { google } from 'googleapis'; +import crypto from 'crypto'; + +export const GMAIL_SCOPES = Object.freeze([ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/userinfo.email', +]); + +const CIPHER = 'aes-256-gcm'; +const IV_BYTES = 16; +const TAG_BYTES = 16; + +const resolveEncryptionKey = () => { + if (process.env.TOKEN_ENCRYPTION_KEY) { + const key = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY, 'hex'); + if (key.length === 32) return key; + } + + return crypto + .createHash('sha256') + .update(process.env.GOOGLE_CLIENT_SECRET || 'fallback-secret') + .digest(); +}; + +export const buildOAuthClient = () => { + return new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + process.env.GOOGLE_REDIRECT_URI || + 'http://localhost:5000/api/gmail/callback' + ); +}; + +export const createAuthUrl = (stateToken) => { + const client = buildOAuthClient(); + + return client.generateAuthUrl({ + access_type: 'offline', + scope: GMAIL_SCOPES, + state: stateToken, + prompt: 'consent', + }); +}; + +export const fetchTokensFromCode = async (authCode) => { + const client = buildOAuthClient(); + const { tokens } = await client.getToken(authCode); + return tokens; +}; + +export const fetchGmailAddress = async (accessToken) => { + const client = buildOAuthClient(); + client.setCredentials({ access_token: accessToken }); + + const gmail = google.gmail({ version: 'v1', auth: client }); + const response = await gmail.users.getProfile({ userId: 'me' }); + + return response.data.emailAddress; +}; + +export const refreshExpiredAccessToken = async (encryptedRefreshToken) => { + const client = buildOAuthClient(); + + const refreshToken = decrypt(encryptedRefreshToken); + client.setCredentials({ refresh_token: refreshToken }); + + const { credentials } = await client.refreshAccessToken(); + return credentials; +}; + +export const encrypt = (plainText) => { + const key = resolveEncryptionKey(); + const iv = crypto.randomBytes(IV_BYTES); + + const cipher = crypto.createCipheriv(CIPHER, key, iv); + let cipherText = cipher.update(plainText, 'utf8', 'hex'); + cipherText += cipher.final('hex'); + + const tag = cipher.getAuthTag(); + + return [ + iv.toString('hex'), + tag.toString('hex'), + cipherText, + ].join(':'); +}; + +export const decrypt = (cipherPayload) => { + const [ivHex, tagHex, encrypted] = cipherPayload.split(':'); + + if (!ivHex || !tagHex || !encrypted) { + throw new Error('Malformed encrypted token'); + } + + const key = resolveEncryptionKey(); + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + + const decipher = crypto.createDecipheriv(CIPHER, key, iv); + decipher.setAuthTag(tag); + + let output = decipher.update(encrypted, 'hex', 'utf8'); + output += decipher.final('utf8'); + + return output; +}; + +export const createCsrfState = () => { + return crypto.randomBytes(32).toString('hex'); +}; \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/controllers/gmailAuthController.js b/contributors/ishanrajsingh/server/src/controllers/gmailAuthController.js index 43b5be50..a29cdf38 100644 --- a/contributors/ishanrajsingh/server/src/controllers/gmailAuthController.js +++ b/contributors/ishanrajsingh/server/src/controllers/gmailAuthController.js @@ -1,52 +1,304 @@ -import { oauth2Client, SCOPES } from "../config/googleOAuth.js"; -import GmailToken from "../models/GmailToken.js"; +import { GmailToken } from '../models/GmailToken.js'; +import { + createAuthUrl, + fetchTokensFromCode, + fetchGmailAddress, + refreshExpiredAccessToken, + encrypt, + decrypt, + createCsrfState, +} from '../config/googleOAuth.js'; +import { parseEmails as parseEmailsService, parseAndGroupByService } from '../services/parseEmails.js'; +import { fetchTransactionalEmails } from '../services/fetchEmails.js'; +import { saveEmailSubscriptions } from '../services/subscriptionSave.js'; -//Redirect user to Google OAuth consent page -export const startGoogleOAuth = async (req, res) => { - try { - const authUrl = oauth2Client.generateAuthUrl({ - access_type: "offline", - prompt: "consent", - scope: SCOPES - }); - res.redirect(authUrl); - } catch (error) { - return res.status(500).json({ error: "Failed to initiate Google OAuth" }); +const csrfStore = new Map(); + +export const startGmailAuth = async (req, res) => { + if (!req.user?.id) { + return res.status(401).json({ success: false }); + } + + const csrf = createCsrfState(); + csrfStore.set(csrf, { uid: req.user.id, ts: Date.now() }); + + const limit = Date.now() - 10 * 60 * 1000; + for (const [key, val] of csrfStore.entries()) { + if (val.ts < limit) csrfStore.delete(key); } + + const url = createAuthUrl(csrf); + res.json({ success: true, url }); }; -//Handle callback and store tokens securely -export const googleOAuthCallback = async (req, res) => { - const code = req.query.code; - if (!code) { - return res.status(400).json({ error: "Authorization code missing" }); +export const gmailCallback = async (req, res) => { + const { code, state, error } = req.query; + + const clientUrl = process.env.CLIENT_URL || 'http://localhost:3000'; + + if (error === 'access_denied') { + return res.redirect(`${clientUrl}/settings?gmail=denied`); } + if (!code || !state) { + return res.redirect(`${clientUrl}/settings?gmail=error`); + } + + const record = csrfStore.get(state); + if (!record) { + return res.redirect(`${clientUrl}/settings?gmail=invalid_state`); + } + + csrfStore.delete(state); + try { - const { tokens } = await oauth2Client.getToken(code); + const tokens = await fetchTokensFromCode(code); - // If no refresh token — user denied or token refused - if (!tokens.refresh_token) { - return res.status(401).json({ - error: "Permission denied: Refresh token not provided" - }); + if (!tokens?.access_token || !tokens?.refresh_token) { + return res.redirect(`${clientUrl}/settings?gmail=token_error`); } - // Save tokens in DB securely + const email = await fetchGmailAddress(tokens.access_token); + + const expiry = new Date( + Date.now() + (tokens.expiry_date || 3600 * 1000) + ); + await GmailToken.findOneAndUpdate( - { user: req.user?.id || "default" }, + { userId: record.uid }, { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiryDate: tokens.expiry_date + userId: record.uid, + gmailAddress: email, + encryptedAccessToken: encrypt(tokens.access_token), + encryptedRefreshToken: encrypt(tokens.refresh_token), + accessTokenExpiresAt: expiry, + connectedOn: new Date(), }, { upsert: true } ); - return res - .status(200) - .json({ message: "Gmail connected (read-only) successfully!" }); - } catch (err) { - return res.status(500).json({ error: "OAuth callback failed" }); + res.redirect(`${clientUrl}/settings?gmail=success`); + } catch { + res.redirect(`${clientUrl}/settings?gmail=callback_failed`); + } +}; + +export const gmailStatus = async (req, res) => { + if (!req.user?.id) { + return res.status(401).json({ success: false }); + } + + const record = await GmailToken.findOne({ userId: req.user.id }); + + if (!record) { + return res.json({ success: true, connected: false }); + } + + if (Date.now() > record.accessTokenExpiresAt.getTime()) { + try { + const fresh = await refreshExpiredAccessToken( + record.encryptedRefreshToken + ); + + record.encryptedAccessToken = encrypt(fresh.access_token); + record.accessTokenExpiresAt = new Date( + fresh.expiry_date || Date.now() + 3600 * 1000 + ); + + await record.save(); + } catch { + await GmailToken.deleteOne({ userId: req.user.id }); + return res.json({ success: true, connected: false }); + } + } + + res.json({ + success: true, + connected: true, + email: record.gmailAddress, + connectedOn: record.connectedOn, + }); +}; + +export const disconnectGmail = async (req, res) => { + if (!req.user?.id) { + return res.status(401).json({ success: false }); + } + + const result = await GmailToken.deleteOne({ userId: req.user.id }); + + if (!result.deletedCount) { + return res.status(404).json({ success: false }); } + + res.json({ success: true }); }; + +export const fetchEmails = async (req, res) => { + try { + if (!req.user?.id) { + return res.status(401).json({ + success: false, + error: 'Authentication required', + }); + } + + // Parse query parameters + const maxResults = Math.min(parseInt(req.query.limit) || 20, 100); + const pageToken = req.query.pageToken || null; + const keywords = req.query.keywords + ? req.query.keywords.split(',').map(k => k.trim()) + : undefined; + + const result = await fetchTransactionalEmails(req.user.id, { + maxResults, + pageToken, + keywords, + }); + + res.json({ + success: true, + ...result, + }); + } catch (error) { + // Handle specific error cases + if (error.message === 'Gmail not connected') { + return res.status(400).json({ + success: false, + error: 'Gmail not connected. Please connect your Gmail first.', + }); + } + + // Handle rate limiting + if (error.code === 429 || error.message?.includes('Rate Limit')) { + return res.status(429).json({ + success: false, + error: 'Rate limit exceeded. Please try again later.', + retryAfter: 60, + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to fetch emails', + }); + } +}; + +export const parseEmails = async (req, res) => { + try { + if (!req.user?.id) { + return res.status(401).json({ + success: false, + error: 'Authentication required', + }); + } + + if (req.body.emails && Array.isArray(req.body.emails)) { + const parsed = parseEmailsService(req.body.emails); + const grouped = req.body.groupByService ? parseAndGroupByService(req.body.emails) : null; + + return res.json({ + success: true, + parsed, + grouped, + count: parsed.length, + }); + } + + const maxResults = Math.min(parseInt(req.query.limit) || 20, 100); + const pageToken = req.query.pageToken || null; + + const fetchResult = await fetchTransactionalEmails(req.user.id, { + maxResults, + pageToken, + }); + + if (!fetchResult.emails || fetchResult.emails.length === 0) { + return res.json({ + success: true, + parsed: [], + message: 'No emails to parse', + }); + } + + const parsed = parseEmailsService(fetchResult.emails); + const grouped = parseAndGroupByService(fetchResult.emails); + + res.json({ + success: true, + parsed, + grouped, + count: parsed.length, + nextPageToken: fetchResult.nextPageToken, + }); + } catch (error) { + console.error('[Parse Emails] ERROR:', error.message); + console.error('[Parse Emails] Stack:', error.stack); + + if (error.message === 'Gmail not connected') { + return res.status(400).json({ + success: false, + error: 'Gmail not connected. Please connect your Gmail first.', + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to parse emails', + }); + } +}; + +export const saveSubscriptions = async (req, res) => { + try { + if (!req.user?.id) { + return res.status(401).json({ + success: false, + error: 'Authentication required', + }); + } + + let parsedEmails = req.body.parsedEmails; + + if (!parsedEmails || !Array.isArray(parsedEmails) || parsedEmails.length === 0) { + const maxResults = Math.min(parseInt(req.query.limit) || 50, 100); + + const fetchResult = await fetchTransactionalEmails(req.user.id, { + maxResults, + }); + + if (!fetchResult.emails || fetchResult.emails.length === 0) { + return res.json({ + success: true, + saved: 0, + skipped: 0, + message: 'No emails to process', + }); + } + + parsedEmails = parseEmailsService(fetchResult.emails); + } + + const result = await saveEmailSubscriptions(parsedEmails, req.user.id); + + res.json({ + success: true, + ...result, + }); + } catch (error) { + console.error('[Save Subscriptions] ERROR:', error.message); + + if (error.message === 'Gmail not connected') { + return res.status(400).json({ + success: false, + error: 'Gmail not connected. Please connect your Gmail first.', + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to save subscriptions', + }); + } +}; \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/controllers/gmailFetch.controller.js b/contributors/ishanrajsingh/server/src/controllers/gmailFetch.controller.js deleted file mode 100644 index 153599cd..00000000 --- a/contributors/ishanrajsingh/server/src/controllers/gmailFetch.controller.js +++ /dev/null @@ -1,83 +0,0 @@ -import { google } from "googleapis"; -import { getValidAccessToken } from "../utils/gmailToken.util.js"; - -//Fetch transactional emails from Gmail - -export const fetchTransactionalEmails = async (req, res) => { - try { - const userId = req.user?.id || "default"; // adjust if auth exists - const maxResults = parseInt(req.query.limit) || 10; - - // Get valid access token (auto refresh if expired) - const accessToken = await getValidAccessToken(userId); - - const auth = new google.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - - const gmail = google.gmail({ version: "v1", auth }); - - // Gmail search query (NO full inbox scan) - const query = - "invoice OR subscription OR renewal OR payment"; - - // List matching messages - const listResponse = await gmail.users.messages.list({ - userId: "me", - q: query, - maxResults - }); - - const messages = listResponse.data.messages; - - // Handle empty inbox / no matching emails - if (!messages || messages.length === 0) { - return res.status(200).json({ - success: true, - count: 0, - emails: [] - }); - } - - // Fetch metadata for each message - const emailPromises = messages.map(async (msg) => { - const message = await gmail.users.messages.get({ - userId: "me", - id: msg.id, - format: "metadata", - metadataHeaders: ["Subject", "From", "Date"] - }); - - const headers = message.data.payload.headers; - - const getHeader = (name) => - headers.find((h) => h.name === name)?.value || ""; - - return { - messageId: msg.id, - subject: getHeader("Subject"), - sender: getHeader("From"), - timestamp: message.data.internalDate - }; - }); - - const emails = await Promise.all(emailPromises); - - return res.status(200).json({ - success: true, - count: emails.length, - emails - }); - } catch (error) { - // Handle rate limits & API errors - if (error.code === 429) { - return res.status(429).json({ - error: "Gmail API rate limit exceeded" - }); - } - - return res.status(500).json({ - error: "Failed to fetch transactional emails", - message: error.message - }); - } -}; diff --git a/contributors/ishanrajsingh/server/src/controllers/gmailParse.controller.js b/contributors/ishanrajsingh/server/src/controllers/gmailParse.controller.js deleted file mode 100644 index 1586d906..00000000 --- a/contributors/ishanrajsingh/server/src/controllers/gmailParse.controller.js +++ /dev/null @@ -1,37 +0,0 @@ -import { parseEmailForSubscription } from "../utils/emailParser.util.js"; -import { fetchTransactionalEmails } from "./gmailFetch.controller.js"; - -//Fetch + parse transactional emails into structured data -export const parseSubscriptionEmails = async (req, res) => { - try { - // Reuse existing fetch logic - const fakeRes = { - status: () => fakeRes, - json: (data) => data - }; - - const data = await fetchTransactionalEmails(req, fakeRes); - - // If no emails - if (!data || !data.emails || data.emails.length === 0) { - return res.status(200).json({ - success: true, - parsed: [] - }); - } - - // Parse emails - const parsed = data.emails.map(parseEmailForSubscription); - - return res.status(200).json({ - success: true, - count: parsed.length, - parsed - }); - } catch (error) { - return res.status(500).json({ - error: "Failed to parse subscription emails", - message: error.message - }); - } -}; diff --git a/contributors/ishanrajsingh/server/src/models/GmailToken.js b/contributors/ishanrajsingh/server/src/models/GmailToken.js index 05fbec27..b2620bb4 100644 --- a/contributors/ishanrajsingh/server/src/models/GmailToken.js +++ b/contributors/ishanrajsingh/server/src/models/GmailToken.js @@ -8,3 +8,53 @@ const gmailTokenSchema = new mongoose.Schema({ }); export default mongoose.model("GmailToken", gmailTokenSchema); +import mongoose from 'mongoose'; + +const { Schema, model } = mongoose; + +const GmailOAuthSchema = new Schema( + { + userId: { + type: String, + required: true, + index: true, + unique: true, + }, + + gmailAddress: { + type: String, + required: true, + lowercase: true, + trim: true, + }, + + encryptedAccessToken: { + type: String, + required: true, + }, + + encryptedRefreshToken: { + type: String, + required: true, + }, + + accessTokenExpiresAt: { + type: Date, + required: true, + }, + + connectedOn: { + type: Date, + default: Date.now, + }, + }, + { + timestamps: true, + versionKey: false, + } +); + + +GmailOAuthSchema.index({ userId: 1 }); + +export const GmailToken = model('GmailToken', GmailOAuthSchema); \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/routes/gmailAuth.routes.js b/contributors/ishanrajsingh/server/src/routes/gmailAuth.routes.js index 93ab8073..30b340cf 100644 --- a/contributors/ishanrajsingh/server/src/routes/gmailAuth.routes.js +++ b/contributors/ishanrajsingh/server/src/routes/gmailAuth.routes.js @@ -1,12 +1,23 @@ -import express from "express"; +import { Router } from 'express'; +import requireAuth from '../middleware/requireAuth.js'; import { - startGoogleOAuth, - googleOAuthCallback -} from "../controllers/gmailAuthController.js"; + startGmailAuth, + gmailCallback, + gmailStatus, + disconnectGmail, + fetchEmails, + parseEmails, + saveSubscriptions, +} from '../controllers/gmailAuthController.js'; -const router = express.Router(); +const router = Router(); -router.get("/auth/google", startGoogleOAuth); -router.get("/auth/google/callback", googleOAuthCallback); +router.get('/connect', requireAuth, startGmailAuth); +router.get('/callback', gmailCallback); +router.get('/status', requireAuth, gmailStatus); +router.post('/disconnect', requireAuth, disconnectGmail); +router.get('/emails', requireAuth, fetchEmails); +router.post('/parse', requireAuth, parseEmails); +router.post('/save', requireAuth, saveSubscriptions); -export default router; +export default router; \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/routes/gmailFetch.routes.js b/contributors/ishanrajsingh/server/src/routes/gmailFetch.routes.js deleted file mode 100644 index b3814a3e..00000000 --- a/contributors/ishanrajsingh/server/src/routes/gmailFetch.routes.js +++ /dev/null @@ -1,9 +0,0 @@ -import express from "express"; -import { fetchTransactionalEmails } from "../controllers/gmailFetch.controller.js"; - -const router = express.Router(); - -// GET /api/gmail/transactions?limit=10 -router.get("/gmail/transactions", fetchTransactionalEmails); - -export default router; diff --git a/contributors/ishanrajsingh/server/src/routes/gmailParse.routes.js b/contributors/ishanrajsingh/server/src/routes/gmailParse.routes.js deleted file mode 100644 index dc47f182..00000000 --- a/contributors/ishanrajsingh/server/src/routes/gmailParse.routes.js +++ /dev/null @@ -1,9 +0,0 @@ -import express from "express"; -import { parseSubscriptionEmails } from "../controllers/gmailParse.controller.js"; - -const router = express.Router(); - -// GET /api/gmail/parse-subscriptions -router.get("/gmail/parse-subscriptions", parseSubscriptionEmails); - -export default router; diff --git a/contributors/ishanrajsingh/server/src/services/fetchEmails.js b/contributors/ishanrajsingh/server/src/services/fetchEmails.js new file mode 100644 index 00000000..bed332a2 --- /dev/null +++ b/contributors/ishanrajsingh/server/src/services/fetchEmails.js @@ -0,0 +1,88 @@ +import { GmailToken } from '../models/GmailToken.js'; +import { + searchEmails, + getEmailMetadata, + decryptToken, + refreshAccessToken, + encryptToken, +} from '../config/googleOAuth.js'; + +const SUBSCRIPTION_KEYWORDS = [ + 'invoice', + 'subscription', + 'renewal', + 'payment', + 'receipt', + 'billing', +]; + +const buildSearchQuery = (keywords = SUBSCRIPTION_KEYWORDS) => { + return keywords.map(k => `(${k})`).join(' OR '); +}; + + +const getValidAccessToken = async (userId) => { + const gmailToken = await GmailToken.findOne({ userId }); + + if (!gmailToken) { + throw new Error('Gmail not connected'); + } + + const isExpired = new Date() > new Date(gmailToken.expiresAt); + + if (isExpired) { + const newTokens = await refreshAccessToken(gmailToken.refreshToken); + + gmailToken.accessToken = encryptToken(newTokens.access_token); + gmailToken.expiresAt = new Date(newTokens.expiry_date || Date.now() + 3600 * 1000); + await gmailToken.save(); + + return { accessToken: newTokens.access_token, gmailToken }; + } + + const accessToken = decryptToken(gmailToken.accessToken); + return { accessToken, gmailToken }; +}; + +export const fetchTransactionalEmails = async (userId, options = {}) => { + const { + maxResults = 20, + pageToken = null, + keywords = SUBSCRIPTION_KEYWORDS + } = options; + + const { accessToken } = await getValidAccessToken(userId); + + const query = buildSearchQuery(keywords); + + const searchResult = await searchEmails(accessToken, query, maxResults, pageToken); + + if (!searchResult.messages || searchResult.messages.length === 0) { + return { + emails: [], + nextPageToken: null, + totalEstimate: 0, + message: 'No subscription-related emails found', + }; + } + + const emails = await Promise.all( + searchResult.messages.map(async (msg) => { + try { + return await getEmailMetadata(accessToken, msg.id); + } catch (error) { + return null; + } + }) + ); + + const validEmails = emails.filter(email => email !== null); + + return { + emails: validEmails, + nextPageToken: searchResult.nextPageToken, + totalEstimate: searchResult.resultSizeEstimate, + }; +}; + +export { SUBSCRIPTION_KEYWORDS, buildSearchQuery }; \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/services/parseEmails.js b/contributors/ishanrajsingh/server/src/services/parseEmails.js new file mode 100644 index 00000000..1020891d --- /dev/null +++ b/contributors/ishanrajsingh/server/src/services/parseEmails.js @@ -0,0 +1,180 @@ +const KNOWN_SERVICES = [ + { pattern: /netflix/i, name: 'Netflix' }, + { pattern: /spotify/i, name: 'Spotify' }, + { pattern: /amazon\s*prime/i, name: 'Amazon Prime' }, + { pattern: /disney\s*\+|disneyplus/i, name: 'Disney+' }, + { pattern: /hbo\s*max|hbomax/i, name: 'HBO Max' }, + { pattern: /youtube\s*(premium|music)/i, name: 'YouTube Premium' }, + { pattern: /apple\s*(music|tv|one|arcade)/i, name: 'Apple' }, + { pattern: /google\s*(one|workspace|play)/i, name: 'Google' }, + { pattern: /microsoft\s*365|office\s*365/i, name: 'Microsoft 365' }, + { pattern: /adobe/i, name: 'Adobe' }, + { pattern: /dropbox/i, name: 'Dropbox' }, + { pattern: /slack/i, name: 'Slack' }, + { pattern: /zoom/i, name: 'Zoom' }, + { pattern: /canva/i, name: 'Canva' }, + { pattern: /notion/i, name: 'Notion' }, + { pattern: /github/i, name: 'GitHub' }, + { pattern: /linkedin/i, name: 'LinkedIn' }, + { pattern: /grammarly/i, name: 'Grammarly' }, + { pattern: /nordvpn|expressvpn|surfshark/i, name: 'VPN Service' }, + { pattern: /playstation|ps\s*plus/i, name: 'PlayStation' }, + { pattern: /xbox|game\s*pass/i, name: 'Xbox' }, + { pattern: /nintendo/i, name: 'Nintendo' }, +]; + +const BILLING_PATTERNS = { + monthly: /monthly|per\s*month|\/month|\/mo|each\s*month/i, + yearly: /yearly|annual|per\s*year|\/year|\/yr|each\s*year/i, + weekly: /weekly|per\s*week|\/week|each\s*week/i, +}; + +const TRANSACTION_PATTERNS = { + renewal: /renewal|renewed|renewing|auto.?renew/i, + payment: /payment|paid|charged|charge|transaction/i, + invoice: /invoice|bill|billing|receipt/i, + subscription: /subscription|subscribed|subscribe/i, + trial: /trial|free\s*trial/i, + cancelled: /cancel|cancelled|canceled/i, +}; + +const AMOUNT_PATTERNS = [ + /\$\s*(\d+(?:[.,]\d{1,2})?)/, + /(\d+(?:[.,]\d{1,2})?)\s*(?:USD|dollars?)/i, + /₹\s*(\d+(?:[.,]\d{1,2})?)/, + /(\d+(?:[.,]\d{1,2})?)\s*(?:INR|rupees?)/i, + /€\s*(\d+(?:[.,]\d{1,2})?)/, + /(\d+(?:[.,]\d{1,2})?)\s*(?:EUR|euros?)/i, + /£\s*(\d+(?:[.,]\d{1,2})?)/, + /(\d+(?:[.,]\d{1,2})?)\s*(?:GBP|pounds?)/i, +]; + + +const extractServiceName = (sender = '', subject = '') => { + const combined = `${sender} ${subject}`; + + for (const service of KNOWN_SERVICES) { + if (service.pattern.test(combined)) { + return service.name; + } + } + + const emailMatch = sender.match(/@([a-z0-9.-]+)\./i); + if (emailMatch) { + const domain = emailMatch[1]; + return domain.charAt(0).toUpperCase() + domain.slice(1); + } + + const nameMatch = sender.match(/^([^<]+)/); + if (nameMatch) { + return nameMatch[1].trim(); + } + + return null; +}; + +const detectBillingCycle = (text = '') => { + for (const [cycle, pattern] of Object.entries(BILLING_PATTERNS)) { + if (pattern.test(text)) { + return cycle; + } + } + return null; +}; + +const detectTransactionType = (text = '') => { + const types = []; + for (const [type, pattern] of Object.entries(TRANSACTION_PATTERNS)) { + if (pattern.test(text)) { + types.push(type); + } + } + return types.length > 0 ? types : ['unknown']; +}; + +const extractAmount = (text = '') => { + for (const pattern of AMOUNT_PATTERNS) { + const match = text.match(pattern); + if (match) { + // Parse the amount + const amountStr = match[1].replace(',', '.'); + const amount = parseFloat(amountStr); + + if (!isNaN(amount) && amount > 0 && amount < 10000) { + // Detect currency + let currency = 'USD'; + if (text.includes('₹') || /INR|rupee/i.test(text)) currency = 'INR'; + else if (text.includes('€') || /EUR|euro/i.test(text)) currency = 'EUR'; + else if (text.includes('£') || /GBP|pound/i.test(text)) currency = 'GBP'; + + return { amount, currency }; + } + } + } + return null; +}; + +export const parseEmail = (email) => { + try { + const { messageId, subject = '', sender = '', snippet = '', timestamp } = email; + + const combinedText = `${subject} ${snippet}`; + + const serviceName = extractServiceName(sender, subject); + const billingCycle = detectBillingCycle(combinedText); + const transactionTypes = detectTransactionType(combinedText); + const amountData = extractAmount(combinedText); + + return { + messageId, + parsed: true, + serviceName, + billingCycle, + transactionTypes, + amount: amountData?.amount || null, + currency: amountData?.currency || null, + originalSubject: subject, + originalSender: sender, + timestamp, + confidence: calculateConfidence({ serviceName, billingCycle, amountData }), + }; + } catch (error) { + return { + messageId: email?.messageId, + parsed: false, + error: 'Failed to parse email', + originalSubject: email?.subject, + originalSender: email?.sender, + }; + } +}; + +const calculateConfidence = ({ serviceName, billingCycle, amountData }) => { + let score = 0; + if (serviceName) score += 40; + if (billingCycle) score += 30; + if (amountData) score += 30; + return score; +}; + + +export const parseEmails = (emails = []) => { + return emails.map(email => parseEmail(email)); +}; + +export const parseAndGroupByService = (emails = []) => { + const parsed = parseEmails(emails); + const grouped = {}; + + for (const email of parsed) { + const key = email.serviceName || 'Unknown'; + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push(email); + } + + return grouped; +}; + +export { KNOWN_SERVICES, BILLING_PATTERNS, TRANSACTION_PATTERNS }; \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/services/subscriptionSave.js b/contributors/ishanrajsingh/server/src/services/subscriptionSave.js new file mode 100644 index 00000000..47bb782d --- /dev/null +++ b/contributors/ishanrajsingh/server/src/services/subscriptionSave.js @@ -0,0 +1,185 @@ +import { Subscription } from '../models/Subscription.js'; +import { + BILLING_CYCLES, + SUBSCRIPTION_SOURCES, + SUBSCRIPTION_STATUS, + SUBSCRIPTION_CATEGORIES, +} from '../constants/subscription.constants.js'; + +const SERVICE_CATEGORIES = { + 'Netflix': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'Disney+': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'HBO Max': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'Spotify': SUBSCRIPTION_CATEGORIES.MUSIC, + 'YouTube Premium': SUBSCRIPTION_CATEGORIES.MUSIC, + 'Apple': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'GitHub': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Notion': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Slack': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Canva': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Adobe': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Microsoft 365': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Dropbox': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Google': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'Grammarly': SUBSCRIPTION_CATEGORIES.EDUCATION, + 'LinkedIn': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, + 'VPN Service': SUBSCRIPTION_CATEGORIES.OTHER, + 'PlayStation': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'Xbox': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'Nintendo': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'Amazon Prime': SUBSCRIPTION_CATEGORIES.ENTERTAINMENT, + 'Zoom': SUBSCRIPTION_CATEGORIES.PRODUCTIVITY, +}; + +const mapToSubscription = (parsedEmail, userId) => { + const { + serviceName, + billingCycle, + amount, + currency, + timestamp, + transactionTypes, + } = parsedEmail; + + if (!serviceName) { + return null; + } + + let renewalDate = new Date(); + if (timestamp) { + const parsed = new Date(timestamp); + if (!isNaN(parsed.getTime())) { + renewalDate = parsed; + } + } + + let cycle = BILLING_CYCLES.MONTHLY; + if (billingCycle === 'yearly') cycle = BILLING_CYCLES.YEARLY; + else if (billingCycle === 'weekly') cycle = BILLING_CYCLES.WEEKLY; + else if (billingCycle === 'monthly') cycle = BILLING_CYCLES.MONTHLY; + + const isTrial = transactionTypes?.includes('trial') || false; + + const category = SERVICE_CATEGORIES[serviceName] || SUBSCRIPTION_CATEGORIES.OTHER; + + return { + userId, + name: serviceName, + amount: amount || 0, + currency: currency || 'USD', + billingCycle: cycle, + category, + renewalDate, + isTrial, + source: SUBSCRIPTION_SOURCES.GMAIL, + status: SUBSCRIPTION_STATUS.ACTIVE, + }; +}; + +const findExisting = async (userId, name, amount = null) => { + const query = { + userId, + name: { $regex: new RegExp(`^${name}$`, 'i') }, + }; + + if (amount !== null && amount > 0) { + query.amount = amount; + } + + return await Subscription.findOne(query); +}; + +const saveSubscription = async (subscriptionData) => { + try { + const { userId, name, amount } = subscriptionData; + + const existing = await findExisting(userId, name, amount); + + if (existing) { + return { + saved: false, + subscription: existing, + reason: 'Duplicate subscription already exists', + }; + } + + const subscription = new Subscription(subscriptionData); + await subscription.save(); + + return { + saved: true, + subscription, + reason: 'New subscription created', + }; + } catch (error) { + return { + saved: false, + subscription: null, + reason: `Error: ${error.message}`, + }; + } +}; + +export const saveEmailSubscriptions = async (parsedEmails, userId) => { + const results = []; + let saved = 0; + let skipped = 0; + let errors = 0; + + const seenServices = new Set(); + + for (const email of parsedEmails) { + if (!email.parsed || !email.serviceName) { + continue; + } + + const serviceKey = `${email.serviceName}-${email.amount || 0}`; + if (seenServices.has(serviceKey)) { + results.push({ + serviceName: email.serviceName, + saved: false, + reason: 'Already processed in this batch', + }); + skipped++; + continue; + } + seenServices.add(serviceKey); + + const subscriptionData = mapToSubscription(email, userId); + + if (!subscriptionData) { + results.push({ + serviceName: email.serviceName, + saved: false, + reason: 'Could not map to subscription', + }); + errors++; + continue; + } + + const result = await saveSubscription(subscriptionData); + + results.push({ + serviceName: email.serviceName, + ...result, + }); + + if (result.saved) { + saved++; + } else if (result.reason.includes('Duplicate')) { + skipped++; + } else { + errors++; + } + } + + return { + saved, + skipped, + errors, + total: saved + skipped + errors, + results, + }; +}; + +export { mapToSubscription, findExisting }; \ No newline at end of file diff --git a/contributors/ishanrajsingh/server/src/utils/emailParser.util.js b/contributors/ishanrajsingh/server/src/utils/emailParser.util.js deleted file mode 100644 index 4c2650d8..00000000 --- a/contributors/ishanrajsingh/server/src/utils/emailParser.util.js +++ /dev/null @@ -1,47 +0,0 @@ -//Parse subscription-related data from email metadata -//Uses simple rule-based logic (as required by issue) - -export const parseEmailForSubscription = (email) => { - const subject = email.subject?.toLowerCase() || ""; - const sender = email.sender || ""; - - // service name - let serviceName = "Unknown"; - - // Try extracting from sender email/domain - const senderMatch = sender.match(/<([^>]+)>/); - if (senderMatch) { - const domain = senderMatch[1].split("@")[1]; - if (domain) { - serviceName = domain.split(".")[0]; - } - } - - // Fallback: infer from subject - if (serviceName === "Unknown" && subject) { - serviceName = subject.split(" ")[0]; - } - - // Billing hints - let billingHint = null; - - if (subject.includes("monthly")) billingHint = "monthly"; - else if (subject.includes("renewal")) billingHint = "renewal"; - else if (subject.includes("charged")) billingHint = "charged"; - else if (subject.includes("payment")) billingHint = "payment"; - - // Amount detection - let amount = null; - const amountMatch = subject.match(/₹\s?\d+(\.\d+)?/); - if (amountMatch) { - amount = amountMatch[0]; - } - - return { - messageId: email.messageId, - serviceName, - billingHint, - amount, - timestamp: email.timestamp - }; -}; diff --git a/contributors/ishanrajsingh/server/src/utils/gmailToken.util.js b/contributors/ishanrajsingh/server/src/utils/gmailToken.util.js deleted file mode 100644 index e43537ec..00000000 --- a/contributors/ishanrajsingh/server/src/utils/gmailToken.util.js +++ /dev/null @@ -1,19 +0,0 @@ -import { oauth2Client } from "../config/googleOAuth.js"; -import GmailToken from "../models/GmailToken.js"; - -//Returns a valid Gmail access token. -//Automatically refreshes if expired. -export const getValidAccessToken = async (userId) => { - const tokenData = await GmailToken.findOne({ user: userId }); - - if (!tokenData) { - throw new Error("No Gmail token stored for this user"); - } - - oauth2Client.setCredentials({ - refresh_token: tokenData.refreshToken - }); - - const accessTokenResponse = await oauth2Client.getAccessToken(); - return accessTokenResponse.token; -};