Velar/Backend/index.js

482 lines
15 KiB
JavaScript
Raw Normal View History

2025-08-14 21:54:25 -07:00
require('dotenv').config();
2025-08-07 01:23:03 -07:00
const express = require('express');
const mongoose = require('mongoose');
const axios = require('axios');
const cors = require('cors');
2025-08-14 21:54:25 -07:00
const passport = require('passport');
const session = require('express-session');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const { OAuth2Client } = require('google-auth-library');
2025-08-15 01:27:26 -07:00
const { google } = require('googleapis');
2025-08-07 01:23:03 -07:00
const app = express();
2025-08-14 21:54:25 -07:00
app.use(express.json());
2025-08-07 01:23:03 -07:00
app.use(cors({
2025-08-14 21:54:25 -07:00
origin: process.env.CORS_ORIGIN || '*',
credentials: true
2025-08-07 01:23:03 -07:00
}));
2025-08-14 21:54:25 -07:00
/* ---------- MODELS ---------- */
const userSchema = new mongoose.Schema({
googleId: { type: String, required: true, unique: true },
displayName: String,
email: String,
photo: String,
accessToken: String,
refreshToken: String,
createdAt: { type: Date, default: Date.now }
});
const User = mongoose.model('User', userSchema);
2025-08-07 01:23:03 -07:00
const transactionSchema = new mongoose.Schema({
2025-08-14 21:54:25 -07:00
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
2025-08-19 22:07:44 -07:00
description: { type: String, required: true },
amount: { type: Number, required: true },
category: { type: String, default: "Other" },
2025-08-07 01:23:03 -07:00
date: { type: Date, default: Date.now },
2025-08-19 22:07:44 -07:00
type: { type: String, default: "unknown" },
vendor: String, // Optional vendor/store name
source: { type: String, enum: ['manual', 'voice', 'email'], required: true },
referenceNumber: { type: String, unique: true, sparse: true }, // Unique transaction id for deduplication
2025-08-07 01:23:03 -07:00
});
2025-08-19 22:07:44 -07:00
2025-08-07 01:23:03 -07:00
const Transaction = mongoose.model('Transaction', transactionSchema);
2025-08-14 21:54:25 -07:00
/* ---------- SESSION ---------- */
const sessionOptions = {
secret: process.env.JWT_SECRET || 'change_this_secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax'
}
};
app.use(session(sessionOptions));
app.use(passport.initialize());
app.use(passport.session());
/* ---------- PASSPORT ---------- */
passport.serializeUser((user, done) => {
done(null, user._id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id).lean();
done(null, user);
} catch (err) {
done(err);
}
});
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_REDIRECT_URI
}, async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails && profile.emails[0] && profile.emails[0].value;
const update = {
displayName: profile.displayName,
email,
accessToken,
...(refreshToken ? { refreshToken } : {})
};
const opts = { upsert: true, new: true, setDefaultsOnInsert: true };
const user = await User.findOneAndUpdate({ googleId: profile.id }, update, opts);
return done(null, user);
} catch (err) {
return done(err);
}
}));
/* ---------- GOOGLE OAUTH ROUTES (WEB) ---------- */
app.get('/auth/google', passport.authenticate('google', {
scope: ['profile', 'email', 'https://www.googleapis.com/auth/gmail.readonly'],
accessType: 'offline',
prompt: 'consent'
}));
2025-08-07 01:23:03 -07:00
2025-08-14 21:54:25 -07:00
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/' }),
(req, res) => {
res.redirect('/profile');
}
);
2025-08-07 01:23:03 -07:00
2025-08-14 21:54:25 -07:00
/* ---------- GOOGLE TOKEN LOGIN (MOBILE / FLUTTER) ---------- */
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
app.post('/auth/google/token', async (req, res) => {
try {
const { idToken } = req.body;
const ticket = await client.verifyIdToken({
idToken,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
const user = await User.findOneAndUpdate(
{ googleId: payload.sub },
{
googleId: payload.sub,
displayName: payload.name,
email: payload.email,
photo: payload.picture
},
{ new: true, upsert: true }
);
res.json({ success: true, user });
} catch (err) {
res.status(401).json({ error: 'Invalid token', details: err.message });
}
});
/* ---------- USER ROUTES ---------- */
app.get('/profile', (req, res) => {
if (!req.user) return res.status(401).json({ error: 'Not logged in' });
res.json({
id: req.user._id,
name: req.user.displayName,
email: req.user.email
});
});
app.get('/logout', (req, res) => {
req.logout(err => {
if (err) console.error('Logout error', err);
req.session.destroy(() => {
res.clearCookie('connect.sid', { path: '/' });
res.redirect('/');
});
});
});
/* ---------- TRANSACTION ROUTES (NOW USER-SPECIFIC) ---------- */
2025-08-07 01:23:03 -07:00
app.post('/api/transaction/add', async (req, res) => {
try {
2025-08-14 21:54:25 -07:00
const { description, amount, userId } = req.body;
if (!userId) return res.status(400).json({ error: 'Missing userId' });
2025-08-07 01:23:03 -07:00
2025-08-21 16:54:47 -07:00
const predictRes = await axios.post('http://192.168.1.101:5000/api/predict', {
2025-08-07 01:23:03 -07:00
description,
});
const category = predictRes.data.category || 'Other';
const newTransaction = new Transaction({
2025-08-14 21:54:25 -07:00
userId,
2025-08-07 01:23:03 -07:00
description,
amount,
2025-08-19 22:07:44 -07:00
category,
source: 'manual',
2025-08-07 01:23:03 -07:00
});
await newTransaction.save();
2025-08-14 21:54:25 -07:00
res.status(200).json({ message: '✅ Transaction saved', data: newTransaction });
2025-08-07 01:23:03 -07:00
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/transactions', async (req, res) => {
try {
2025-08-14 21:54:25 -07:00
const { category, userId } = req.query;
if (!userId) return res.status(400).json({ error: 'Missing userId' });
2025-08-07 01:23:03 -07:00
2025-08-14 21:54:25 -07:00
let query = { userId };
if (category && category !== 'All') query.category = category;
2025-08-07 01:23:03 -07:00
const transactions = await Transaction.find(query).sort({ date: -1 });
2025-08-14 21:54:25 -07:00
res.status(200).json({ success: true, data: transactions });
2025-08-07 01:23:03 -07:00
} catch (err) {
res.status(500).json({ error: err.message });
}
});
2025-08-14 21:54:25 -07:00
2025-08-07 01:23:03 -07:00
app.get('/api/transactions/recent', async (req, res) => {
try {
2025-08-14 21:54:25 -07:00
const { userId } = req.query;
if (!userId) return res.status(400).json({ error: 'Missing userId' });
const transactions = await Transaction.find({ userId })
2025-08-07 01:23:03 -07:00
.sort({ date: -1 })
2025-08-14 21:54:25 -07:00
.limit(5);
res.status(200).json({ success: true, data: transactions });
2025-08-07 01:23:03 -07:00
} catch (err) {
res.status(500).json({ error: err.message });
}
});
2025-08-21 16:54:47 -07:00
//VOICE
2025-08-18 22:43:55 -07:00
app.post('/api/transactions/voice', async (req, res) => {
try {
const { voiceInput } = req.body;
if (!voiceInput) {
return res.status(400).json({ error: 'No voice input provided' });
}
// Step 1: Use regex to extract amount
const amountMatch = voiceInput.match(/(?:\₹|\$)?(\d+(?:\.\d{1,2})?)/);
const amount = amountMatch ? parseFloat(amountMatch[1]) : null;
// Step 2: Extract description (rough logic: remove common verbs + amount)
const cleaned = voiceInput
.toLowerCase()
.replace(/(bought|added|paid|spent|for|on)/g, '')
.replace(/₹?\d+/, '')
.trim();
const description = cleaned || 'misc';
// Step 3: Predict category using your Flask API
2025-08-21 16:54:47 -07:00
const predictRes = await axios.post('http://192.168.1.101:5000/api/predict', {
2025-08-18 22:43:55 -07:00
description,
});
const category = predictRes.data?.category || 'Other';
// Step 4: Save to DB
const newTransaction = new Transaction({
description,
amount,
category,
2025-08-19 22:07:44 -07:00
source: 'voice'
2025-08-18 22:43:55 -07:00
});
await newTransaction.save();
res.status(200).json({
message: '✅ Voice transaction saved',
data: newTransaction,
});
} catch (err) {
console.error('❌ Voice transaction error:', err.message);
res.status(500).json({ error: 'Server error during voice transaction' });
}
});
2025-08-15 01:27:26 -07:00
// Bank rules (can expand later)
2025-08-18 23:38:58 -07:00
// ---------- BANK RULES ----------
2025-08-15 01:27:26 -07:00
const bankRules = [
{ name: 'HDFC Bank', email: 'alerts@hdfcbank.net' },
2025-08-18 23:38:58 -07:00
{ name: 'ICICI Bank', email: 'alerts@icicibank.com' },
2025-08-15 01:27:26 -07:00
];
2025-08-18 23:38:58 -07:00
// ---------- PARSER ----------
function parseBankMessage(snippet) {
const result = {
amount: null,
vendor: null,
type: null,
date: null,
2025-08-21 16:54:47 -07:00
referenceNumber: null,
2025-08-18 23:38:58 -07:00
};
2025-08-21 16:54:47 -07:00
// Amount
2025-08-18 23:38:58 -07:00
const amtMatch = snippet.match(/(?:Rs\.?|INR)\s*([\d,]+\.?\d*)/i);
if (amtMatch) result.amount = parseFloat(amtMatch[1].replace(/,/g, ""));
// Debit / Credit
if (/debited/i.test(snippet)) result.type = "debit";
else if (/credited/i.test(snippet)) result.type = "credit";
2025-08-21 16:54:47 -07:00
// Date
const dateMatch = snippet.match(/on\s+(\d{2}[-/]\d{2}[-/]\d{2,4})/i);
if (dateMatch) result.date = dateMatch[1];
// Reference Number (flexible regex)
const refMatch = snippet.match(/(?:reference number is|UPI reference number is|Ref No:?)\s*([A-Za-z0-9]+)/i);
if (refMatch) result.referenceNumber = refMatch[1].trim();
// Vendor extraction - remove emails, pick last uppercase phrase
let vendorCandidate = snippet.replace(/[a-z0-9._%+-]+@[a-z0-9.-]+/gi, '').trim();
const vendorWords = vendorCandidate.match(/(?:\b[A-Z]{2,}\b(?:\s)?)+/g);
if (vendorWords && vendorWords.length > 0) {
result.vendor = vendorWords[vendorWords.length - 1].trim();
2025-08-18 23:38:58 -07:00
} else {
2025-08-21 16:54:47 -07:00
// Fallback: extract after "to " or "at "
const toMatch = snippet.match(/to\s+([A-Za-z0-9\s&.-]+)/i);
if (toMatch) {
let v = toMatch[1].trim();
v = v.replace(/[a-z0-9._%+-]+@[a-z0-9.-]+/gi, '').trim();
v = v.replace(/\s{2,}/g, ' ');
result.vendor = v;
2025-08-19 09:46:35 -07:00
}
2025-08-18 23:38:58 -07:00
}
return result;
}
// ---------- FETCH EMAILS ----------
2025-08-15 01:27:26 -07:00
async function fetchBankEmails(accessToken, bankEmails) {
const oauth2Client = new google.auth.OAuth2();
oauth2Client.setCredentials({ access_token: accessToken });
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
const allEmails = [];
for (const bankEmail of bankEmails) {
const res = await gmail.users.messages.list({
userId: 'me',
q: `from:${bankEmail}`,
maxResults: 20, // can adjust
});
const messages = res.data.messages || [];
for (const msg of messages) {
const fullMessage = await gmail.users.messages.get({
userId: 'me',
id: msg.id,
});
2025-08-21 16:54:47 -07:00
let body = "";
if (fullMessage.data.payload?.parts) {
for (const part of fullMessage.data.payload.parts) {
if (part.mimeType === "text/plain" && part.body?.data) {
body += Buffer.from(part.body.data, "base64").toString("utf-8");
}
if (part.mimeType === "text/html" && part.body?.data) {
body += Buffer.from(part.body.data, "base64").toString("utf-8");
}
}
}
const snippet = body || fullMessage.data.snippet || "";
2025-08-15 01:27:26 -07:00
allEmails.push({
from: bankEmail,
snippet,
});
}
}
return allEmails;
}
2025-08-18 23:38:58 -07:00
// ---------- ROUTE ----------
2025-08-15 01:27:26 -07:00
app.post('/api/sync-gmail', async (req, res) => {
try {
2025-08-19 09:46:35 -07:00
const { accessToken, userId } = req.body;
2025-08-15 01:27:26 -07:00
if (!accessToken) {
2025-08-19 09:46:35 -07:00
console.error('❌ Missing access token in request body');
2025-08-15 01:27:26 -07:00
return res.status(400).json({ error: "Missing access token" });
}
2025-08-19 09:46:35 -07:00
if (!userId) {
console.error('❌ Missing userId in request body');
return res.status(400).json({ error: "Missing userId" });
}
2025-08-15 01:27:26 -07:00
const bankEmails = bankRules.map(b => b.email);
2025-08-19 09:46:35 -07:00
let emails;
try {
emails = await fetchBankEmails(accessToken, bankEmails);
} catch (fetchError) {
console.error('❌ Error fetching bank emails:', fetchError);
return res.status(500).json({ error: 'Failed to fetch bank emails' });
}
if (!emails || emails.length === 0) {
console.warn('⚠️ No bank emails found for provided email accounts');
return res.json({ success: true, count: 0, transactions: [] });
}
const savedTxns = [];
for (const email of emails) {
let parsed;
try {
2025-08-21 16:54:47 -07:00
// console.log('📧 Processing email snippet:', email.snippet);
2025-08-19 09:46:35 -07:00
parsed = parseBankMessage(email.snippet);
} catch (parseError) {
console.error('❌ Error parsing email snippet:', parseError, 'Snippet:', email.snippet);
continue; // Skip this email and move on
}
if (!parsed.amount) {
console.warn('⚠️ Parsed email missing amount, skipping:', parsed);
continue;
}
2025-08-19 22:07:44 -07:00
2025-08-19 09:46:35 -07:00
let category = 'Other';
try {
2025-08-21 16:54:47 -07:00
const predictRes = await axios.post('http://192.168.1.101:5000/api/predict', {
2025-08-19 09:46:35 -07:00
description: parsed.vendor || "Unknown"
}, { timeout: 5000 }); // Optional timeout
if (predictRes.data && typeof predictRes.data.category === 'string' && predictRes.data.category.trim() !== '') {
category = predictRes.data.category;
} else {
console.warn('⚠️ Prediction API returned invalid category for vendor:', parsed.vendor);
}
} catch (predictError) {
console.error('❌ Prediction API error:', predictError.message);
}
const parseDateString = (dateStr) => {
2025-08-19 22:07:44 -07:00
if (!dateStr) return null;
const [day, month, yearSuffix] = dateStr.split('-');
const year = 2000 + parseInt(yearSuffix, 10);
return new Date(year, parseInt(month, 10) - 1, parseInt(day, 10));
};
2025-08-19 09:46:35 -07:00
2025-08-19 22:07:44 -07:00
const dateValue = parseDateString(parsed.date) || new Date();
2025-08-19 09:46:35 -07:00
try {
2025-08-21 16:54:47 -07:00
if (parsed.referenceNumber) {
2025-08-19 22:07:44 -07:00
const updatedTxn = await Transaction.findOneAndUpdate(
2025-08-21 16:54:47 -07:00
{ referenceNumber: parsed.referenceNumber },
2025-08-19 22:07:44 -07:00
{
userId,
description: parsed.vendor || "Unknown Vendor",
amount: parsed.amount,
type: parsed.type || "unknown",
date: dateValue,
vendor: parsed.vendor,
category,
2025-08-21 16:54:47 -07:00
source: "email",
2025-08-19 22:07:44 -07:00
bank: email.from,
2025-08-21 16:54:47 -07:00
referenceNumber: parsed.referenceNumber,
2025-08-19 22:07:44 -07:00
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
savedTxns.push(updatedTxn);
2025-08-21 16:54:47 -07:00
} else {
const newTxn = new Transaction({
userId,
description: parsed.vendor || "Unknown Vendor",
amount: parsed.amount,
type: parsed.type || "unknown",
date: dateValue,
vendor: parsed.vendor,
category,
source: "email", // mark source explicitly
bank: email.from,
});
await newTxn.save();
savedTxns.push(newTxn);
2025-08-19 09:46:35 -07:00
}
2025-08-21 16:54:47 -07:00
} catch (saveError) {
console.error('❌ Error saving transaction to database:', saveError);
2025-08-19 09:46:35 -07:00
}
2025-08-15 01:27:26 -07:00
}
2025-08-21 16:54:47 -07:00
return res.json({
success: true,
count: savedTxns.length,
transactions: savedTxns,
});
} catch (error) {
console.error('❌ Unexpected error in /api/sync-gmail:', error);
return res.status(500).json({ error: 'Failed to fetch and save Gmail messages' });
}
2025-08-15 01:27:26 -07:00
});
2025-08-14 21:54:25 -07:00
/* ---------- SERVER ---------- */
mongoose.connect(process.env.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB connection error:', err));
2025-08-07 01:23:03 -07:00
2025-08-14 21:54:25 -07:00
const HOST = process.env.HOST || '0.0.0.0';
2025-08-07 01:23:03 -07:00
const PORT = process.env.PORT || 3000;
app.listen(PORT, HOST, () => {
console.log(`🚀 Server running on http://${HOST}:${PORT}`);
});