diff --git a/contributors/Krishna200608/client/package.json b/contributors/Krishna200608/client/package.json index 37f322d..e466f7d 100644 --- a/contributors/Krishna200608/client/package.json +++ b/contributors/Krishna200608/client/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@clerk/nextjs": "^6.36.5", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", @@ -26,6 +27,7 @@ "react": "19.2.3", "react-day-picker": "^9.13.0", "react-dom": "19.2.3", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/contributors/Krishna200608/client/src/app/components/subscriptions/AddSubscriptionForm.tsx b/contributors/Krishna200608/client/src/app/components/subscriptions/AddSubscriptionForm.tsx index 462e942..eafb038 100644 --- a/contributors/Krishna200608/client/src/app/components/subscriptions/AddSubscriptionForm.tsx +++ b/contributors/Krishna200608/client/src/app/components/subscriptions/AddSubscriptionForm.tsx @@ -5,6 +5,7 @@ import { useAuth } from '@clerk/nextjs'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; import { CreditCard, Calendar, @@ -222,6 +223,10 @@ export default function AddSubscriptionForm() { }); setSubmitStatus('success'); + + toast.success('Subscription created', { + description: `${formData.name} has been added successfully.`, + }); // Redirect after success setTimeout(() => { diff --git a/contributors/Krishna200608/client/src/app/components/subscriptions/DeleteConfirmDialog.tsx b/contributors/Krishna200608/client/src/app/components/subscriptions/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..d103492 --- /dev/null +++ b/contributors/Krishna200608/client/src/app/components/subscriptions/DeleteConfirmDialog.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { AlertTriangle, Trash2, Loader2, X } from 'lucide-react'; +import { Subscription } from '@/lib/api'; +import { cn, formatCurrency } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { getServiceIcon, getServiceColors } from '@/lib/service-icons'; +import * as AlertDialog from '@radix-ui/react-alert-dialog'; + +interface DeleteConfirmDialogProps { + subscription: Subscription | null; + isOpen: boolean; + onClose: () => void; + onConfirm: (id: string) => Promise; +} + +export default function DeleteConfirmDialog({ + subscription, + isOpen, + onClose, + onConfirm, +}: DeleteConfirmDialogProps) { + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(''); + + const serviceIcon = subscription ? getServiceIcon(subscription.name) : null; + const serviceColors = subscription ? getServiceColors(subscription.name) : null; + + const handleConfirm = async () => { + if (!subscription) return; + + setIsDeleting(true); + setError(''); + + try { + await onConfirm(subscription._id); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete subscription'); + } finally { + setIsDeleting(false); + } + }; + + const handleClose = () => { + if (!isDeleting) { + setError(''); + onClose(); + } + }; + + if (!subscription) return null; + + return ( + !open && handleClose()}> + + + + + + + {/* Warning Icon */} +
+
+ +
+
+ + {/* Title */} + + Delete Subscription? + + + {/* Description */} + + This action cannot be undone. The subscription will be permanently removed from your account. + + + {/* Subscription Preview */} +
+
+
+ {serviceIcon || ( + + {subscription.name.charAt(0).toUpperCase()} + + )} +
+
+

{subscription.name}

+

+ {formatCurrency(subscription.amount, subscription.currency)} / {subscription.billingCycle} +

+
+
+
+ + {/* Error */} + {error && ( + +

{error}

+
+ )} + + {/* Actions */} +
+ + + + +
+
+
+
+
+ ); +} diff --git a/contributors/Krishna200608/client/src/app/components/subscriptions/EditSubscriptionModal.tsx b/contributors/Krishna200608/client/src/app/components/subscriptions/EditSubscriptionModal.tsx new file mode 100644 index 0000000..a5d94a9 --- /dev/null +++ b/contributors/Krishna200608/client/src/app/components/subscriptions/EditSubscriptionModal.tsx @@ -0,0 +1,545 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + X, + CreditCard, + DollarSign, + Clock, + Tag, + Calendar, + Zap, + Loader2, + CheckCircle2, + AlertCircle, + Save, +} from 'lucide-react'; +import { Subscription } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { DatePicker } from '@/components/ui/date-picker'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getServiceIcon, getServiceColors } from '@/lib/service-icons'; +import * as Dialog from '@radix-ui/react-dialog'; + +interface EditSubscriptionModalProps { + subscription: Subscription | null; + isOpen: boolean; + onClose: () => void; + onSave: (id: string, data: Partial) => Promise; +} + +interface FormData { + name: string; + amount: string; + currency: string; + billingCycle: string; + category: string; + renewalDate: Date | undefined; + isTrial: boolean; + trialEndsAt: Date | undefined; + status: string; + notes: string; +} + +interface FormErrors { + name?: string; + amount?: string; + billingCycle?: string; + category?: string; + renewalDate?: string; + trialEndsAt?: string; +} + +const categories = [ + { value: 'entertainment', label: 'Entertainment', color: 'text-purple-400' }, + { value: 'music', label: 'Music', color: 'text-pink-400' }, + { value: 'education', label: 'Education', color: 'text-blue-400' }, + { value: 'productivity', label: 'Productivity', color: 'text-green-400' }, + { value: 'finance', label: 'Finance', color: 'text-yellow-400' }, + { value: 'health', label: 'Health & Fitness', color: 'text-red-400' }, + { value: 'other', label: 'Other', color: 'text-gray-400' }, +]; + +const billingCycles = [ + { value: 'monthly', label: 'Monthly' }, + { value: 'yearly', label: 'Yearly' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'custom', label: 'Custom' }, +]; + +const currencies = [ + { value: 'USD', label: 'USD ($)', symbol: '$' }, + { value: 'EUR', label: 'EUR (€)', symbol: '€' }, + { value: 'GBP', label: 'GBP (£)', symbol: '£' }, + { value: 'INR', label: 'INR (₹)', symbol: '₹' }, +]; + +const statuses = [ + { value: 'active', label: 'Active' }, + { value: 'paused', label: 'Paused' }, + { value: 'cancelled', label: 'Cancelled' }, +]; + +export default function EditSubscriptionModal({ + subscription, + isOpen, + onClose, + onSave, +}: EditSubscriptionModalProps) { + const [formData, setFormData] = useState({ + name: '', + amount: '', + currency: 'USD', + billingCycle: '', + category: '', + renewalDate: undefined, + isTrial: false, + trialEndsAt: undefined, + status: 'active', + notes: '', + }); + + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [errorMessage, setErrorMessage] = useState(''); + + // Prefill form when subscription changes + useEffect(() => { + if (subscription) { + setFormData({ + name: subscription.name, + amount: subscription.amount.toString(), + currency: subscription.currency, + billingCycle: subscription.billingCycle, + category: subscription.category, + renewalDate: new Date(subscription.renewalDate), + isTrial: subscription.isTrial, + trialEndsAt: subscription.trialEndsAt ? new Date(subscription.trialEndsAt) : undefined, + status: subscription.status, + notes: '', + }); + setErrors({}); + setSubmitStatus('idle'); + setErrorMessage(''); + } + }, [subscription]); + + const serviceIcon = getServiceIcon(formData.name); + const serviceColors = getServiceColors(formData.name); + + const validateForm = (): boolean => { + const newErrors: FormErrors = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Name is required'; + } + + if (!formData.amount) { + newErrors.amount = 'Amount is required'; + } else if (isNaN(parseFloat(formData.amount)) || parseFloat(formData.amount) <= 0) { + newErrors.amount = 'Please enter a valid positive amount'; + } + + if (!formData.billingCycle) { + newErrors.billingCycle = 'Please select a billing cycle'; + } + + if (!formData.category) { + newErrors.category = 'Please select a category'; + } + + if (!formData.renewalDate) { + newErrors.renewalDate = 'Renewal date is required'; + } + + if (formData.isTrial && !formData.trialEndsAt) { + newErrors.trialEndsAt = 'Trial end date is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleChange = (field: keyof FormData, value: string | boolean | Date | undefined) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field as keyof FormErrors]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + if (submitStatus !== 'idle') { + setSubmitStatus('idle'); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm() || !subscription) return; + + setIsSubmitting(true); + setSubmitStatus('idle'); + setErrorMessage(''); + + try { + await onSave(subscription._id, { + name: formData.name.trim(), + amount: parseFloat(formData.amount), + currency: formData.currency, + billingCycle: formData.billingCycle as 'monthly' | 'yearly' | 'weekly' | 'custom', + category: formData.category as 'entertainment' | 'music' | 'education' | 'productivity' | 'finance' | 'health' | 'other', + renewalDate: formData.renewalDate!.toISOString(), + isTrial: formData.isTrial, + trialEndsAt: formData.isTrial && formData.trialEndsAt ? formData.trialEndsAt.toISOString() : undefined, + status: formData.status as 'active' | 'paused' | 'cancelled', + }); + + setSubmitStatus('success'); + setTimeout(() => { + onClose(); + }, 1000); + } catch (error) { + setSubmitStatus('error'); + setErrorMessage(error instanceof Error ? error.message : 'Failed to update subscription'); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + onClose(); + } + }; + + return ( + !open && handleClose()}> + + + + + + + {/* Header */} +
+
+
+ {serviceIcon || ( + + {formData.name.charAt(0).toUpperCase() || 'E'} + + )} +
+
+ + Edit Subscription + + + Update subscription details + +
+
+ + + +
+ + {/* Form */} +
+ {/* Name */} +
+ + handleChange('name', e.target.value)} + error={!!errors.name} + /> + {errors.name && ( +

+ + {errors.name} +

+ )} +
+ + {/* Amount and Currency */} +
+
+ + handleChange('amount', e.target.value)} + error={!!errors.amount} + /> + {errors.amount && ( +

+ + {errors.amount} +

+ )} +
+ +
+ + +
+
+ + {/* Billing Cycle and Category */} +
+
+ + + {errors.billingCycle && ( +

+ + {errors.billingCycle} +

+ )} +
+ +
+ + + {errors.category && ( +

+ + {errors.category} +

+ )} +
+
+ + {/* Renewal Date and Status */} +
+
+ + handleChange('renewalDate', date)} + placeholder="Select date" + error={!!errors.renewalDate} + /> + {errors.renewalDate && ( +

+ + {errors.renewalDate} +

+ )} +
+ +
+ + +
+
+ + {/* Trial Toggle */} +
+
+
+
+ +
+
+

Free Trial

+

Is this a trial?

+
+
+ handleChange('isTrial', checked)} + /> +
+ + + {formData.isTrial && ( + +
+ +
+ handleChange('trialEndsAt', date)} + placeholder="Select trial end date" + error={!!errors.trialEndsAt} + /> +
+ {errors.trialEndsAt && ( +

+ + {errors.trialEndsAt} +

+ )} +
+
+ )} +
+
+ + {/* Submit Status */} + + {submitStatus === 'success' && ( + + +

Subscription Updated!

+
+ )} + + {submitStatus === 'error' && ( + + +
+

Update Failed

+

{errorMessage}

+
+
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+
+
+
+ ); +} diff --git a/contributors/Krishna200608/client/src/app/components/subscriptions/SubscriptionCard.tsx b/contributors/Krishna200608/client/src/app/components/subscriptions/SubscriptionCard.tsx index 80d9c86..a4246e2 100644 --- a/contributors/Krishna200608/client/src/app/components/subscriptions/SubscriptionCard.tsx +++ b/contributors/Krishna200608/client/src/app/components/subscriptions/SubscriptionCard.tsx @@ -42,9 +42,11 @@ interface SubscriptionCardProps { subscription: Subscription; view?: 'grid' | 'list'; index?: number; + onEdit?: (subscription: Subscription) => void; + onDelete?: (subscription: Subscription) => void; } -export default function SubscriptionCard({ subscription, view = 'grid', index = 0 }: SubscriptionCardProps) { +export default function SubscriptionCard({ subscription, view = 'grid', index = 0, onEdit, onDelete }: SubscriptionCardProps) { const daysUntil = getDaysUntilRenewal(subscription.renewalDate); const isUrgent = isUrgentRenewal(subscription.renewalDate); const categoryColors = getCategoryColor(subscription.category); @@ -166,7 +168,7 @@ export default function SubscriptionCard({ subscription, view = 'grid', index = - + onEdit?.(subscription)}> Edit @@ -176,8 +178,8 @@ export default function SubscriptionCard({ subscription, view = 'grid', index = Visit Site - - Cancel + onDelete?.(subscription)}> + Delete @@ -244,7 +246,7 @@ export default function SubscriptionCard({ subscription, view = 'grid', index = - + onEdit?.(subscription)}> Edit @@ -254,8 +256,8 @@ export default function SubscriptionCard({ subscription, view = 'grid', index = Visit Site - - Cancel + onDelete?.(subscription)}> + Delete diff --git a/contributors/Krishna200608/client/src/app/components/subscriptions/index.ts b/contributors/Krishna200608/client/src/app/components/subscriptions/index.ts index 65f9afd..7484152 100644 --- a/contributors/Krishna200608/client/src/app/components/subscriptions/index.ts +++ b/contributors/Krishna200608/client/src/app/components/subscriptions/index.ts @@ -4,5 +4,7 @@ export { default as SortDropdown } from './SortDropdown'; export { default as ViewToggle } from './ViewToggle'; export { default as EmptyState } from './EmptyState'; export { default as QuickStats } from './QuickStats'; +export { default as EditSubscriptionModal } from './EditSubscriptionModal'; +export { default as DeleteConfirmDialog } from './DeleteConfirmDialog'; export type { FilterStatus, FilterBillingCycle, FilterCategory } from './FilterBar'; export type { SortField, SortOrder } from './SortDropdown'; diff --git a/contributors/Krishna200608/client/src/app/layout.tsx b/contributors/Krishna200608/client/src/app/layout.tsx index 35641d3..39038d7 100644 --- a/contributors/Krishna200608/client/src/app/layout.tsx +++ b/contributors/Krishna200608/client/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ClerkProvider } from "@clerk/nextjs"; +import { Toaster } from "sonner"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -28,7 +29,21 @@ export default function RootLayout({ - {children} + + {children} + + ); diff --git a/contributors/Krishna200608/client/src/app/subscriptions/page.tsx b/contributors/Krishna200608/client/src/app/subscriptions/page.tsx index 536d2f2..e918cdb 100644 --- a/contributors/Krishna200608/client/src/app/subscriptions/page.tsx +++ b/contributors/Krishna200608/client/src/app/subscriptions/page.tsx @@ -5,6 +5,7 @@ import { useAuth } from '@clerk/nextjs'; import Link from 'next/link'; import { Plus, Loader2, RefreshCw } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; import DashboardLayout from '../components/DashboardLayout'; import { SubscriptionCard, @@ -13,13 +14,15 @@ import { ViewToggle, EmptyState, QuickStats, + EditSubscriptionModal, + DeleteConfirmDialog, FilterStatus, FilterBillingCycle, FilterCategory, SortField, SortOrder, } from '../components/subscriptions'; -import { Subscription, getSubscriptions } from '@/lib/api'; +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'; @@ -42,6 +45,10 @@ export default function SubscriptionsPage() { const [sortField, setSortField] = useState('renewalDate'); const [sortOrder, setSortOrder] = useState('asc'); + // Edit/Delete modal state + const [editingSubscription, setEditingSubscription] = useState(null); + const [deletingSubscription, setDeletingSubscription] = useState(null); + // Fetch subscriptions from real API useEffect(() => { const fetchSubscriptions = async () => { @@ -57,6 +64,9 @@ export default function SubscriptionsPage() { setSubscriptions(data.subscriptions || []); } catch (err) { setError('Failed to load subscriptions. Make sure the server is running.'); + toast.error('Failed to load subscriptions', { + description: 'Make sure the server is running.', + }); console.error(err); } finally { setIsLoading(false); @@ -130,12 +140,51 @@ export default function SubscriptionsPage() { setSubscriptions(data.subscriptions || []); } catch (err) { setError('Failed to refresh subscriptions'); + toast.error('Failed to refresh', { + description: 'Could not refresh subscriptions.', + }); console.error(err); } finally { setIsLoading(false); } }; + // Handle edit subscription + const handleEditSubscription = async (id: string, data: Partial) => { + const token = await getToken(); + if (!token) throw new Error('Authentication required'); + + const result = await updateSubscription(token, id, data); + + // Update local state immediately + setSubscriptions(prev => + prev.map(sub => sub._id === id ? result.subscription : sub) + ); + + toast.success('Subscription updated', { + description: `${result.subscription.name} has been updated successfully.`, + }); + }; + + // Handle delete subscription + const handleDeleteSubscription = async (id: string) => { + const token = await getToken(); + if (!token) throw new Error('Authentication required'); + + // Get subscription name before deleting + const subscription = subscriptions.find(s => s._id === id); + const subName = subscription?.name || 'Subscription'; + + await deleteSubscription(token, id); + + // Remove from local state immediately + setSubscriptions(prev => prev.filter(sub => sub._id !== id)); + + toast.success('Subscription deleted', { + description: `${subName} has been permanently removed.`, + }); + }; + return ( {/* Quick Stats */} @@ -244,12 +293,30 @@ export default function SubscriptionsPage() { subscription={subscription} view={view} index={index} + onEdit={setEditingSubscription} + onDelete={setDeletingSubscription} /> ))} )} + + {/* Edit Modal */} + setEditingSubscription(null)} + onSave={handleEditSubscription} + /> + + {/* Delete Confirmation Dialog */} + setDeletingSubscription(null)} + onConfirm={handleDeleteSubscription} + /> ); } diff --git a/contributors/Krishna200608/client/src/lib/api.ts b/contributors/Krishna200608/client/src/lib/api.ts index 29028d7..4498780 100644 --- a/contributors/Krishna200608/client/src/lib/api.ts +++ b/contributors/Krishna200608/client/src/lib/api.ts @@ -56,3 +56,43 @@ export async function createSubscription( return response.json(); } + +export async function updateSubscription( + token: string, + id: string, + data: Partial> +): Promise<{ message: string; subscription: Subscription }> { + const response = await fetch(`${API_BASE_URL}/api/subscriptions/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error('Failed to update subscription'); + } + + return response.json(); +} + +export async function deleteSubscription( + token: string, + id: string +): Promise<{ message: string }> { + const response = await fetch(`${API_BASE_URL}/api/subscriptions/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to delete subscription'); + } + + return response.json(); +} diff --git a/contributors/Krishna200608/server/package.json b/contributors/Krishna200608/server/package.json new file mode 100644 index 0000000..73d84e3 --- /dev/null +++ b/contributors/Krishna200608/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "SubSentry Backend Server", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js" + }, + "type": "module", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.0.0", + "express": "^4.17.1", + "mongoose": "^6.0.0", + "nodemon": "^3.1.11" + } +} diff --git a/contributors/Krishna200608/server/src/app.js b/contributors/Krishna200608/server/src/app.js new file mode 100644 index 0000000..6e39e2e --- /dev/null +++ b/contributors/Krishna200608/server/src/app.js @@ -0,0 +1,18 @@ +import cors from 'cors'; +import express from 'express'; +import subscriptionRoutes from './routes/subscription.routes.js'; +import attachUser from './middleware/attachUser.js'; + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(attachUser); + +app.get('/', (_, res) => { + res.send('SubSentry API running'); +}); + +app.use('/api/subscriptions', subscriptionRoutes); + +export default app; diff --git a/contributors/Krishna200608/server/src/config/db.js b/contributors/Krishna200608/server/src/config/db.js new file mode 100644 index 0000000..9fb8b29 --- /dev/null +++ b/contributors/Krishna200608/server/src/config/db.js @@ -0,0 +1,33 @@ +import mongoose from 'mongoose'; + +const MONGO_URI = process.env.MONGO_URI; + +if (!MONGO_URI) { + throw new Error('❌ Please define MONGO_URI in environment variables'); +} + +mongoose.set('strictQuery', false); + +let cached = global.mongoose; + +if (!cached) { + cached = global.mongoose = { conn: null, promise: null }; +} + +export async function connectDB() { + if (cached.conn) return cached.conn; + + if (!cached.promise) { + cached.promise = mongoose.connect(MONGO_URI); + } + + try { + cached.conn = await cached.promise; + console.log('MongoDB connected'); + return cached.conn; + } catch (err) { + cached.promise = null; + console.error('MongoDB connection failed', err); + throw err; + } +} diff --git a/contributors/Krishna200608/server/src/constants/subscription.constants.js b/contributors/Krishna200608/server/src/constants/subscription.constants.js new file mode 100644 index 0000000..e34a98e --- /dev/null +++ b/contributors/Krishna200608/server/src/constants/subscription.constants.js @@ -0,0 +1,30 @@ +export const BILLING_CYCLES = Object.freeze({ + MONTHLY: 'monthly', + YEARLY: 'yearly', + WEEKLY: 'weekly', + CUSTOM: 'custom', +}); + +export const SUBSCRIPTION_SOURCES = Object.freeze({ + MANUAL: 'manual', + GMAIL: 'gmail', + IMPORTED: 'imported', +}); + +export const SUBSCRIPTION_STATUS = Object.freeze({ + ACTIVE: 'active', + PAUSED: 'paused', + CANCELLED: 'cancelled', +}); + +export const SUBSCRIPTION_CATEGORIES = Object.freeze({ + ENTERTAINMENT: 'entertainment', + MUSIC: 'music', + EDUCATION: 'education', + PRODUCTIVITY: 'productivity', + FINANCE: 'finance', + HEALTH: 'health', + OTHER: 'other', +}); + +export const DEFAULT_CURRENCY = 'USD'; diff --git a/contributors/Krishna200608/server/src/controllers/subscription.controller.js b/contributors/Krishna200608/server/src/controllers/subscription.controller.js new file mode 100644 index 0000000..15646e7 --- /dev/null +++ b/contributors/Krishna200608/server/src/controllers/subscription.controller.js @@ -0,0 +1,124 @@ +import { Subscription } from '../models/Subscription.js'; +import { validateCreateSubscription } from '../validators/subscription.validator.js'; + +export const createSubscription = async (req, res) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const error = validateCreateSubscription(req.body); + if (error) { + return res.status(400).json({ message: error }); + } + + const subscription = new Subscription({ + ...req.body, + userId, + }); + + const savedSubscription = await subscription.save(); + + return res.status(201).json({ + message: 'Subscription created successfully', + subscription: savedSubscription, + }); + } catch (error) { + return res.status(500).json({ + message: 'Failed to create subscription', + error: error.message, + }); + } +}; + +export const getUserSubscriptions = async (req, res) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const subscriptions = await Subscription.find({ userId }) + .sort({ renewalDate: 1 }) + .select('-__v'); + + return res.status(200).json({ subscriptions }); + } catch (error) { + return res.status(500).json({ + message: 'Failed to fetch subscriptions', + error: error.message, + }); + } +}; + +export const updateSubscription = async (req, res) => { + try { + const userId = req.user?.id; + const { id } = req.params; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Find the subscription and verify ownership + const subscription = await Subscription.findOne({ _id: id, userId }); + + if (!subscription) { + return res.status(404).json({ message: 'Subscription not found' }); + } + + // Update fields + const allowedUpdates = [ + 'name', 'amount', 'currency', 'billingCycle', 'category', + 'renewalDate', 'isTrial', 'trialEndsAt', 'source', 'status' + ]; + + allowedUpdates.forEach(field => { + if (req.body[field] !== undefined) { + subscription[field] = req.body[field]; + } + }); + + const updatedSubscription = await subscription.save(); + + return res.status(200).json({ + message: 'Subscription updated successfully', + subscription: updatedSubscription, + }); + } catch (error) { + return res.status(500).json({ + message: 'Failed to update subscription', + error: error.message, + }); + } +}; + +export const deleteSubscription = async (req, res) => { + try { + const userId = req.user?.id; + const { id } = req.params; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Find and delete the subscription, verifying ownership + const subscription = await Subscription.findOneAndDelete({ _id: id, userId }); + + if (!subscription) { + return res.status(404).json({ message: 'Subscription not found' }); + } + + return res.status(200).json({ + message: 'Subscription deleted successfully', + }); + } catch (error) { + return res.status(500).json({ + message: 'Failed to delete subscription', + error: error.message, + }); + } +}; diff --git a/contributors/Krishna200608/server/src/middleware/attachUser.js b/contributors/Krishna200608/server/src/middleware/attachUser.js new file mode 100644 index 0000000..eb97bc1 --- /dev/null +++ b/contributors/Krishna200608/server/src/middleware/attachUser.js @@ -0,0 +1,32 @@ +const decodeJwtPayload = (token) => { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + try { + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = payload.padEnd(payload.length + (4 - (payload.length % 4)) % 4, '='); + const json = Buffer.from(padded, 'base64').toString('utf-8'); + return JSON.parse(json); + } catch { + return null; + } +}; + +const attachUser = (req, _res, next) => { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; + + if (token) { + const payload = decodeJwtPayload(token); + const userId = payload?.sub || payload?.userId || payload?.uid || token; + req.user = { id: userId }; + } else if (req.headers['x-user-id']) { + req.user = { id: req.headers['x-user-id'] }; + } + + next(); +}; + +export default attachUser; diff --git a/contributors/Krishna200608/server/src/middleware/requireAuth.js b/contributors/Krishna200608/server/src/middleware/requireAuth.js new file mode 100644 index 0000000..fadc3f6 --- /dev/null +++ b/contributors/Krishna200608/server/src/middleware/requireAuth.js @@ -0,0 +1,9 @@ +const requireAuth = (req, res, next) => { + if (!req.user || !req.user.id) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + return next(); +}; + +export default requireAuth; diff --git a/contributors/Krishna200608/server/src/models/Subscription.js b/contributors/Krishna200608/server/src/models/Subscription.js new file mode 100644 index 0000000..f236ae9 --- /dev/null +++ b/contributors/Krishna200608/server/src/models/Subscription.js @@ -0,0 +1,67 @@ +import { Schema, model } from 'mongoose'; +import { + BILLING_CYCLES, + SUBSCRIPTION_CATEGORIES, + SUBSCRIPTION_SOURCES, + SUBSCRIPTION_STATUS, + DEFAULT_CURRENCY, +} from '../constants/subscription.constants.js'; + +const subscriptionSchema = new Schema( + { + userId: { + type: String, + required: true, + index: true, + }, + name: { + type: String, + required: true, + trim: true, + }, + amount: { + type: Number, + required: true, + min: 0, + }, + currency: { + type: String, + default: DEFAULT_CURRENCY, + uppercase: true, + }, + billingCycle: { + type: String, + enum: Object.values(BILLING_CYCLES), + required: true, + }, + category: { + type: String, + enum: Object.values(SUBSCRIPTION_CATEGORIES), + default: SUBSCRIPTION_CATEGORIES.OTHER, + }, + renewalDate: { + type: Date, + required: true, + }, + isTrial: { + type: Boolean, + default: false, + }, + trialEndsAt: { + type: Date, + }, + source: { + type: String, + enum: Object.values(SUBSCRIPTION_SOURCES), + default: SUBSCRIPTION_SOURCES.MANUAL, + }, + status: { + type: String, + enum: Object.values(SUBSCRIPTION_STATUS), + default: SUBSCRIPTION_STATUS.ACTIVE, + }, + }, + { timestamps: true } +); + +export const Subscription = model('Subscription', subscriptionSchema); diff --git a/contributors/Krishna200608/server/src/routes/subscription.routes.js b/contributors/Krishna200608/server/src/routes/subscription.routes.js new file mode 100644 index 0000000..d8d37e8 --- /dev/null +++ b/contributors/Krishna200608/server/src/routes/subscription.routes.js @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import requireAuth from '../middleware/requireAuth.js'; +import { + createSubscription, + getUserSubscriptions, + updateSubscription, + deleteSubscription, +} from '../controllers/subscription.controller.js'; + +const router = Router(); + +router.post('/', requireAuth, createSubscription); +router.get('/', requireAuth, getUserSubscriptions); +router.put('/:id', requireAuth, updateSubscription); +router.delete('/:id', requireAuth, deleteSubscription); + +export default router; diff --git a/contributors/Krishna200608/server/src/server.js b/contributors/Krishna200608/server/src/server.js new file mode 100644 index 0000000..ffa41e4 --- /dev/null +++ b/contributors/Krishna200608/server/src/server.js @@ -0,0 +1,16 @@ +import 'dotenv/config'; + +import app from './app.js'; +import { connectDB } from './config/db.js'; + +const PORT = process.env.PORT || 5000; + +async function startServer() { + await connectDB(); + + app.listen(PORT, () => { + console.log(`SubSentry API running on port ${PORT}`); + }); +} + +startServer(); diff --git a/contributors/Krishna200608/server/src/validators/subscription.validator.js b/contributors/Krishna200608/server/src/validators/subscription.validator.js new file mode 100644 index 0000000..150cc41 --- /dev/null +++ b/contributors/Krishna200608/server/src/validators/subscription.validator.js @@ -0,0 +1,25 @@ +import { BILLING_CYCLES } from '../constants/subscription.constants.js'; + +export const validateCreateSubscription = (data) => { + const requiredFields = ['name', 'amount', 'billingCycle', 'renewalDate']; + + for (const field of requiredFields) { + if (!data[field]) { + return `Missing required field: ${field}`; + } + } + + if (typeof data.amount !== 'number' || data.amount < 0) { + return 'Amount must be a non-negative number'; + } + + if (!Object.values(BILLING_CYCLES).includes(data.billingCycle)) { + return 'Invalid billing cycle'; + } + + if (data.isTrial && !data.trialEndsAt) { + return 'trialEndsAt is required when isTrial is true'; + } + + return null; +};