|
@@ -1,1754 +0,0 @@
|
|
|
-const express = require('express');
|
|
|
|
|
-const crypto = require('crypto');
|
|
|
|
|
-const axios = require('axios');
|
|
|
|
|
-const { createClient } = require('@supabase/supabase-js');
|
|
|
|
|
-const path = require('path');
|
|
|
|
|
-const nodemailer = require('nodemailer');
|
|
|
|
|
-const app = express();
|
|
|
|
|
-require('dotenv').config();
|
|
|
|
|
-const cors = require('cors');
|
|
|
|
|
-const querystring = require('querystring');
|
|
|
|
|
-
|
|
|
|
|
-app.use(cors());
|
|
|
|
|
-
|
|
|
|
|
-// Add raw body parser for webhooks BEFORE any other middleware
|
|
|
|
|
-app.use('/gdpr', express.raw({ type: '*/*' }));
|
|
|
|
|
-
|
|
|
|
|
-// Regular middleware for other routes
|
|
|
|
|
-app.use(express.json());
|
|
|
|
|
-app.use(express.urlencoded({ extended: true }));
|
|
|
|
|
-app.use(express.static('public'));
|
|
|
|
|
-
|
|
|
|
|
-// Supabase configuration
|
|
|
|
|
-const supabaseUrl = process.env.SUPABASE_URL;
|
|
|
|
|
-const supabaseKey = process.env.SUPABASE_ANON_KEY;
|
|
|
|
|
-
|
|
|
|
|
-if (!supabaseUrl || !supabaseKey) {
|
|
|
|
|
- console.error('Missing Supabase configuration. Please set SUPABASE_URL and SUPABASE_ANON_KEY in your .env file');
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const supabase = createClient(supabaseUrl, supabaseKey);
|
|
|
|
|
-
|
|
|
|
|
-// Email transporter configuration
|
|
|
|
|
-const emailTransporter = nodemailer.createTransport({
|
|
|
|
|
- service: 'gmail', // You can change this to your email provider
|
|
|
|
|
- auth: {
|
|
|
|
|
- user: process.env.EMAIL_USER || 'srwusc123@gmail.com',
|
|
|
|
|
- pass: process.env.EMAIL_PASSWORD || 'sach zpxx dqec hhtq'
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to generate OTP
|
|
|
|
|
-function generateOTP() {
|
|
|
|
|
- return Math.floor(100000 + Math.random() * 900000).toString(); // 6-digit OTP
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to send OTP email
|
|
|
|
|
-async function sendOTPEmail(email, otp, userName) {
|
|
|
|
|
- const mailOptions = {
|
|
|
|
|
- from: process.env.EMAIL_USER || 'srwusc123@gmail.com',
|
|
|
|
|
- to: email,
|
|
|
|
|
- subject: 'Verify Your Account - ShopCall.ai',
|
|
|
|
|
- html: `
|
|
|
|
|
- <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
|
|
|
- <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; text-align: center; margin-bottom: 30px;">
|
|
|
|
|
- <h1 style="color: white; margin: 0; font-size: 2rem;">SHOPCALL.AI</h1>
|
|
|
|
|
- <p style="color: white; margin: 10px 0 0 0; opacity: 0.9;">Account Verification</p>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div style="background: #f8f9fa; padding: 30px; border-radius: 10px; text-align: center;">
|
|
|
|
|
- <h2 style="color: #333; margin: 0 0 20px 0;">Hello ${userName}!</h2>
|
|
|
|
|
- <p style="color: #666; margin-bottom: 30px; font-size: 16px;">
|
|
|
|
|
- Please use the following verification code to complete your account setup:
|
|
|
|
|
- </p>
|
|
|
|
|
-
|
|
|
|
|
- <div style="background: white; border: 2px solid #667eea; border-radius: 10px; padding: 20px; margin: 20px 0; display: inline-block;">
|
|
|
|
|
- <div style="font-size: 32px; font-weight: bold; color: #667eea; letter-spacing: 5px;">${otp}</div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <p style="color: #666; font-size: 14px; margin-top: 30px;">
|
|
|
|
|
- This code will expire in 15 minutes. If you didn't request this verification, please ignore this email.
|
|
|
|
|
- </p>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div style="text-align: center; margin-top: 30px; color: #999; font-size: 12px;">
|
|
|
|
|
- <p>© 2024 AI Caller. All rights reserved.</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- `
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- await emailTransporter.sendMail(mailOptions);
|
|
|
|
|
- return { success: true };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Email sending error:', error);
|
|
|
|
|
- return { success: false, error: error.message };
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Environment URLs
|
|
|
|
|
-const BACKEND_URL = process.env.BACKEND_URL || (process.env.NODE_ENV === 'production'
|
|
|
|
|
- ? 'https://shopcall-ai-backend.vercel.app'
|
|
|
|
|
- : 'http://localhost:3000');
|
|
|
|
|
-
|
|
|
|
|
-const FRONTEND_URL = process.env.FRONTEND_URL || (process.env.NODE_ENV === 'production'
|
|
|
|
|
- ? 'https://shopcall.ai'
|
|
|
|
|
- : 'http://localhost:8080');
|
|
|
|
|
-
|
|
|
|
|
-// Shopify Configuration
|
|
|
|
|
-const config = {
|
|
|
|
|
- apiKey: process.env.SHOPIFY_API_KEY,
|
|
|
|
|
- apiSecret: process.env.SHOPIFY_API_SECRET,
|
|
|
|
|
- scopes: 'read_products,write_products,read_orders,write_orders,read_customers,write_customers',
|
|
|
|
|
- redirectUri: `${BACKEND_URL}/auth/shopify/callback`
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// ShopRenter Configuration
|
|
|
|
|
-const shoprenterConfig = {
|
|
|
|
|
- clientId: process.env.SHOPRENTER_CLIENT_ID,
|
|
|
|
|
- clientSecret: process.env.SHOPRENTER_CLIENT_SECRET,
|
|
|
|
|
- scopes: 'product:read product:write customer:read customer:write order:read order:write webhook:read webhook:write',
|
|
|
|
|
- redirectUri: `${BACKEND_URL}/auth/shoprenter/callback`,
|
|
|
|
|
- entryPoint: `${BACKEND_URL}/auth/shoprenter`,
|
|
|
|
|
- uninstallUri: `${BACKEND_URL}/auth/shoprenter/uninstall`
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Store nonces temporarily (use Redis or database in production)
|
|
|
|
|
-const nonceStore = new Map();
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to generate nonce
|
|
|
|
|
-function generateNonce() {
|
|
|
|
|
- return 'nonce_' + crypto.randomBytes(16).toString('hex');
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to normalize shop URL
|
|
|
|
|
-function normalizeShopUrl(shop) {
|
|
|
|
|
- if (!shop) return null;
|
|
|
|
|
-
|
|
|
|
|
- // Remove protocol if present
|
|
|
|
|
- shop = shop.replace(/^https?:\/\//, '');
|
|
|
|
|
-
|
|
|
|
|
- // Remove trailing slash if present
|
|
|
|
|
- shop = shop.replace(/\/$/, '');
|
|
|
|
|
-
|
|
|
|
|
- // Remove any path after the domain
|
|
|
|
|
- shop = shop.split('/')[0];
|
|
|
|
|
-
|
|
|
|
|
- return shop;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to validate shop URL
|
|
|
|
|
-function isValidShopUrl(shop) {
|
|
|
|
|
- if (!shop) return false;
|
|
|
|
|
-
|
|
|
|
|
- // Normalize the shop URL
|
|
|
|
|
- const normalizedShop = normalizeShopUrl(shop);
|
|
|
|
|
-
|
|
|
|
|
- // Check if it's a valid Shopify shop URL
|
|
|
|
|
- // This regex allows for:
|
|
|
|
|
- // - Letters, numbers, and hyphens in the subdomain
|
|
|
|
|
- // - .myshopify.com domain
|
|
|
|
|
- // - Optional .com, .co.uk, etc. TLDs
|
|
|
|
|
- const shopRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
|
|
|
|
|
-
|
|
|
|
|
- return shopRegex.test(normalizedShop);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ShopRenter HMAC validation function
|
|
|
|
|
-function validateShopRenterHMAC(query, clientSecret) {
|
|
|
|
|
- const { hmac, ...params } = query;
|
|
|
|
|
-
|
|
|
|
|
- if (!hmac) {
|
|
|
|
|
- return { valid: false, error: 'Missing HMAC parameter' };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- // Sort parameters alphabetically and create query string
|
|
|
|
|
- const sortedParams = Object.keys(params)
|
|
|
|
|
- .sort()
|
|
|
|
|
- .map(key => `${key}=${params[key]}`)
|
|
|
|
|
- .join('&');
|
|
|
|
|
-
|
|
|
|
|
- // Calculate HMAC using SHA256
|
|
|
|
|
- const calculatedHmac = crypto
|
|
|
|
|
- .createHmac('sha256', clientSecret)
|
|
|
|
|
- .update(sortedParams)
|
|
|
|
|
- .digest('hex');
|
|
|
|
|
-
|
|
|
|
|
- // Compare HMACs using timing-safe comparison
|
|
|
|
|
- const hmacValid = crypto.timingSafeEqual(
|
|
|
|
|
- Buffer.from(calculatedHmac),
|
|
|
|
|
- Buffer.from(hmac)
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- if (!hmacValid) {
|
|
|
|
|
- return { valid: false, error: 'Invalid HMAC signature' };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return { valid: true };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('HMAC validation error:', error);
|
|
|
|
|
- return { valid: false, error: 'HMAC validation failed' };
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Middleware to verify ShopRenter requests
|
|
|
|
|
-const verifyShopRenterRequest = (req, res, next) => {
|
|
|
|
|
- const validation = validateShopRenterHMAC(req.query, shoprenterConfig.clientSecret);
|
|
|
|
|
-
|
|
|
|
|
- if (!validation.valid) {
|
|
|
|
|
- console.error('ShopRenter request validation failed:', validation.error);
|
|
|
|
|
- return res.status(403).json({
|
|
|
|
|
- error: 'Invalid request signature',
|
|
|
|
|
- details: validation.error
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- next();
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Secured Session Middleware
|
|
|
|
|
-const securedSession = async (req, res, next) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const authHeader = req.headers.authorization;
|
|
|
|
|
- if (!authHeader) {
|
|
|
|
|
- return res.status(401).json({ error: 'No authorization header' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const token = authHeader.replace('Bearer ', '');
|
|
|
|
|
- const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
|
|
|
|
-
|
|
|
|
|
- if (authError || !user) {
|
|
|
|
|
- return res.status(401).json({ error: 'Invalid token' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Attach user to request object for use in route handlers
|
|
|
|
|
- req.user = user;
|
|
|
|
|
- next();
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Session verification error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Authentication Routes
|
|
|
|
|
-
|
|
|
|
|
-// Serve login page
|
|
|
|
|
-app.get('/login', (req, res) => {
|
|
|
|
|
- res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Serve signup page
|
|
|
|
|
-app.get('/signup', (req, res) => {
|
|
|
|
|
- res.sendFile(path.join(__dirname, 'public', 'signup.html'));
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Store pending signups temporarily (use Redis or database in production)
|
|
|
|
|
-const pendingSignups = new Map();
|
|
|
|
|
-
|
|
|
|
|
-// Signup endpoint - Step 1: Store user data and send OTP
|
|
|
|
|
-app.post('/auth/signup', async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { full_name, company_name, user_name, email, password } = req.body;
|
|
|
|
|
-
|
|
|
|
|
- // Validate required fields
|
|
|
|
|
- if (!full_name || !company_name || !user_name || !email || !password) {
|
|
|
|
|
- return res.status(400).json({ error: 'All fields are required' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Generate OTP
|
|
|
|
|
- const otp = generateOTP();
|
|
|
|
|
-
|
|
|
|
|
- // Store pending signup data with OTP
|
|
|
|
|
- const signupId = crypto.randomBytes(16).toString('hex');
|
|
|
|
|
- pendingSignups.set(signupId, {
|
|
|
|
|
- full_name,
|
|
|
|
|
- company_name,
|
|
|
|
|
- user_name,
|
|
|
|
|
- email,
|
|
|
|
|
- password,
|
|
|
|
|
- otp,
|
|
|
|
|
- timestamp: Date.now(),
|
|
|
|
|
- expiresAt: Date.now() + (15 * 60 * 1000) // 15 minutes
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // Send OTP to email for verification
|
|
|
|
|
- const emailResult = await sendOTPEmail(email, otp, full_name);
|
|
|
|
|
-
|
|
|
|
|
- if (!emailResult.success) {
|
|
|
|
|
- pendingSignups.delete(signupId);
|
|
|
|
|
- return res.status(500).json({ error: 'Failed to send verification email. Please try again.' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'OTP sent to your email. Please verify to complete registration.',
|
|
|
|
|
- signupId: signupId,
|
|
|
|
|
- requiresOtpVerification: true
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Signup error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Signup verification endpoint - Step 2: Verify OTP and create account
|
|
|
|
|
-app.post('/auth/signup/verify', async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { signupId, otp } = req.body;
|
|
|
|
|
-
|
|
|
|
|
- if (!signupId || !otp) {
|
|
|
|
|
- return res.status(400).json({ error: 'Signup ID and OTP are required' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Get pending signup data
|
|
|
|
|
- const pendingData = pendingSignups.get(signupId);
|
|
|
|
|
- if (!pendingData) {
|
|
|
|
|
- return res.status(400).json({ error: 'Invalid or expired signup session' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Check expiration
|
|
|
|
|
- if (Date.now() > pendingData.expiresAt) {
|
|
|
|
|
- pendingSignups.delete(signupId);
|
|
|
|
|
- return res.status(400).json({ error: 'Signup session expired. Please start again.' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Verify OTP (compare with stored OTP)
|
|
|
|
|
- if (otp !== pendingData.otp) {
|
|
|
|
|
- return res.status(400).json({ error: 'Invalid OTP code' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // OTP verified, now create the user account
|
|
|
|
|
- const { data, error } = await supabase.auth.signUp({
|
|
|
|
|
- email: pendingData.email,
|
|
|
|
|
- password: pendingData.password,
|
|
|
|
|
- options: {
|
|
|
|
|
- data: {
|
|
|
|
|
- is_verified: true,
|
|
|
|
|
- full_name: pendingData.full_name,
|
|
|
|
|
- company_name: pendingData.company_name,
|
|
|
|
|
- user_name: pendingData.user_name
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- return res.status(400).json({ error: error.message });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Clean up pending signup
|
|
|
|
|
- pendingSignups.delete(signupId);
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'Account created successfully! You can now sign in.',
|
|
|
|
|
- user: data.user
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Signup verification error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Login endpoint - Simple email/password authentication
|
|
|
|
|
-app.post('/auth/login', async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { email, password } = req.body;
|
|
|
|
|
-
|
|
|
|
|
- if (!email || !password) {
|
|
|
|
|
- return res.status(400).json({ error: 'Email and password are required' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Sign in with email and password
|
|
|
|
|
- const { data, error } = await supabase.auth.signInWithPassword({
|
|
|
|
|
- email: email,
|
|
|
|
|
- password: password
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- console.log(error);
|
|
|
|
|
- return res.status(400).json({ error: 'Invalid email or password' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'Login successful',
|
|
|
|
|
- user: data.user,
|
|
|
|
|
- session: data.session
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Login error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Logout endpoint
|
|
|
|
|
-app.post('/auth/logout', async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { error } = await supabase.auth.signOut();
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- return res.status(400).json({ error: error.message });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'Logout successful'
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Logout error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Get current user
|
|
|
|
|
-app.get('/auth/check', async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const authHeader = req.headers.authorization;
|
|
|
|
|
- if (!authHeader) {
|
|
|
|
|
- return res.status(401).json({ error: 'No authorization header' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const token = authHeader.replace('Bearer ', '');
|
|
|
|
|
- const { data: { user }, error } = await supabase.auth.getUser(token);
|
|
|
|
|
-
|
|
|
|
|
- if (error || !user) {
|
|
|
|
|
- return res.status(401).json({ error: 'Invalid token' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- user: user
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Get user error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-// Resend OTP for signup verification
|
|
|
|
|
-app.post('/auth/signup/resend-otp', async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { signupId } = req.body;
|
|
|
|
|
-
|
|
|
|
|
- if (!signupId) {
|
|
|
|
|
- return res.status(400).json({ error: 'Signup ID is required' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Get pending signup data
|
|
|
|
|
- const pendingData = pendingSignups.get(signupId);
|
|
|
|
|
- if (!pendingData) {
|
|
|
|
|
- return res.status(400).json({ error: 'Invalid or expired signup session' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Check if still valid
|
|
|
|
|
- if (Date.now() > pendingData.expiresAt) {
|
|
|
|
|
- pendingSignups.delete(signupId);
|
|
|
|
|
- return res.status(400).json({ error: 'Signup session expired. Please start again.' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Generate new OTP
|
|
|
|
|
- const newOtp = generateOTP();
|
|
|
|
|
-
|
|
|
|
|
- // Update the stored OTP
|
|
|
|
|
- pendingData.otp = newOtp;
|
|
|
|
|
- pendingSignups.set(signupId, pendingData);
|
|
|
|
|
-
|
|
|
|
|
- // Send new OTP
|
|
|
|
|
- const emailResult = await sendOTPEmail(pendingData.email, newOtp, pendingData.full_name);
|
|
|
|
|
-
|
|
|
|
|
- if (!emailResult.success) {
|
|
|
|
|
- return res.status(500).json({ error: 'Failed to send verification email' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'New OTP sent to your email'
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Resend OTP error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Health check
|
|
|
|
|
-app.get('/health', (req, res) => {
|
|
|
|
|
- res.json({
|
|
|
|
|
- status: 'OK',
|
|
|
|
|
- environment: process.env.NODE_ENV || 'development',
|
|
|
|
|
- redirectUri: config.redirectUri
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Webshop Authentication Routes
|
|
|
|
|
-
|
|
|
|
|
-// Route 1: Initialize OAuth (redirect merchant to Shopify)
|
|
|
|
|
-app.get('/auth/shopify', (req, res) => {
|
|
|
|
|
- const { shop } = req.query;
|
|
|
|
|
-
|
|
|
|
|
- // Validate shop parameter
|
|
|
|
|
- if (!shop) {
|
|
|
|
|
- console.error('Missing shop parameter');
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Shop parameter is required',
|
|
|
|
|
- details: 'Please provide a valid Shopify shop URL'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Normalize and validate shop URL
|
|
|
|
|
- const normalizedShop = normalizeShopUrl(shop);
|
|
|
|
|
- if (!isValidShopUrl(normalizedShop)) {
|
|
|
|
|
- console.error(`Invalid shop URL: ${shop}`);
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Invalid shop URL',
|
|
|
|
|
- details: 'Please provide a valid Shopify shop URL (e.g., your-store.myshopify.com)'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Generate and store nonce with user ID
|
|
|
|
|
- const nonce = generateNonce();
|
|
|
|
|
- nonceStore.set(nonce, {
|
|
|
|
|
- shop: normalizedShop,
|
|
|
|
|
- userId: req?.user?.id, // Store the authenticated user's ID
|
|
|
|
|
- timestamp: Date.now(),
|
|
|
|
|
- expiresAt: Date.now() + (10 * 60 * 1000) // 10 minutes
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // Build authorization URL
|
|
|
|
|
- const authUrl = `https://${normalizedShop}/admin/oauth/authorize?client_id=${config.apiKey}&scope=${config.scopes}&redirect_uri=${config.redirectUri}&state=${nonce}&grant_options[]=per-user`;
|
|
|
|
|
-
|
|
|
|
|
- console.log(`Initiating OAuth flow for shop: ${normalizedShop}`);
|
|
|
|
|
- res.redirect(authUrl);
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-app.get('/auth/woocommerce', securedSession, (req, res) => {
|
|
|
|
|
- const { shop_url, phone_number, package, platform } = req.query;
|
|
|
|
|
- const store_url = shop_url;
|
|
|
|
|
- const endpoint = '/wc-auth/v1/authorize';
|
|
|
|
|
- const params = {
|
|
|
|
|
- app_name: 'ShopCall.ai',
|
|
|
|
|
- scope: 'read_write',
|
|
|
|
|
- user_id: req.user.id,
|
|
|
|
|
- return_url: `${FRONTEND_URL}/dashboard`,
|
|
|
|
|
- callback_url: `${BACKEND_URL}/auth/woocommerce/callback?platform=${platform}&shop_url=${shop_url}&phone_number=${phone_number}&package=${package}`
|
|
|
|
|
- };
|
|
|
|
|
- const query_string = querystring.stringify(params).replace(/%20/g, '+');
|
|
|
|
|
- const redirect_url = store_url + endpoint + '?' + query_string;
|
|
|
|
|
- console.log("redirect_url", redirect_url);
|
|
|
|
|
- res.redirect(redirect_url);
|
|
|
|
|
-})
|
|
|
|
|
-
|
|
|
|
|
-// Route 2: Handle Woocommerce Callback
|
|
|
|
|
-app.post('/auth/woocommerce/callback', async (req, res) => {
|
|
|
|
|
- const { platform, shop_url, phone_number, package } = req.query;
|
|
|
|
|
- const { key_id, user_id, consumer_key, consumer_secret, key_permissions } = req.body;
|
|
|
|
|
- console.log("req.body", req.body);
|
|
|
|
|
- try {
|
|
|
|
|
- // Store WooCommerce store data in Supabase
|
|
|
|
|
- const { data: storeData, error: storeError } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .insert({
|
|
|
|
|
- user_id: user_id, // This should be the authenticated user's ID
|
|
|
|
|
- platform_name: platform,
|
|
|
|
|
- store_name: new URL(shop_url).hostname.split('.')[0], // Extract store name from URL
|
|
|
|
|
- store_url: shop_url,
|
|
|
|
|
- api_key: consumer_key,
|
|
|
|
|
- api_secret: consumer_secret,
|
|
|
|
|
- scopes: key_permissions ? [key_permissions] : ['read_write'],
|
|
|
|
|
- alt_data: {
|
|
|
|
|
- key_id: key_id,
|
|
|
|
|
- permissions: key_permissions
|
|
|
|
|
- },
|
|
|
|
|
- phone_number: phone_number,
|
|
|
|
|
- package: package
|
|
|
|
|
- })
|
|
|
|
|
- .select()
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (storeError) {
|
|
|
|
|
- console.error('Error storing WooCommerce store data:', storeError);
|
|
|
|
|
- return res.status(500).json({
|
|
|
|
|
- success: false,
|
|
|
|
|
- error: 'Failed to store store data'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`Successfully stored WooCommerce store data for: ${shop_url}`);
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'Woocommerce Credentials Received Successfully...',
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('WooCommerce callback error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- success: false,
|
|
|
|
|
- error: 'Failed to process WooCommerce callback'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// ShopRenter OAuth Routes
|
|
|
|
|
-
|
|
|
|
|
-// Route 1: Initialize ShopRenter OAuth (Entry Point)
|
|
|
|
|
-app.get('/auth/shoprenter', verifyShopRenterRequest, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { shopname, app_url, timestamp } = req.query;
|
|
|
|
|
-
|
|
|
|
|
- if (!shopname || !app_url) {
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Missing required parameters',
|
|
|
|
|
- details: 'shopname and app_url are required'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`ShopRenter OAuth initiated for shop: ${shopname}`);
|
|
|
|
|
-
|
|
|
|
|
- // Store the app_url for the callback
|
|
|
|
|
- const nonce = generateNonce();
|
|
|
|
|
- nonceStore.set(nonce, {
|
|
|
|
|
- shopname: shopname,
|
|
|
|
|
- app_url: app_url,
|
|
|
|
|
- timestamp: Date.now(),
|
|
|
|
|
- expiresAt: Date.now() + (10 * 60 * 1000) // 10 minutes
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // Build authorization URL
|
|
|
|
|
- const authParams = new URLSearchParams({
|
|
|
|
|
- response_type: 'code',
|
|
|
|
|
- client_id: shoprenterConfig.clientId,
|
|
|
|
|
- scope: shoprenterConfig.scopes,
|
|
|
|
|
- redirect_uri: shoprenterConfig.redirectUri,
|
|
|
|
|
- state: nonce
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const authUrl = `${app_url}/admin/oauth/authorize?${authParams.toString()}`;
|
|
|
|
|
-
|
|
|
|
|
- console.log(`Redirecting to ShopRenter authorization: ${authUrl}`);
|
|
|
|
|
- res.redirect(authUrl);
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('ShopRenter OAuth initialization error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Failed to initialize OAuth',
|
|
|
|
|
- details: error.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Route 2: Handle ShopRenter OAuth Callback
|
|
|
|
|
-app.get('/auth/shoprenter/callback', async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { shopname, code, timestamp, hmac, app_url, state } = req.query;
|
|
|
|
|
-
|
|
|
|
|
- // Validate HMAC
|
|
|
|
|
- const validation = validateShopRenterHMAC(req.query, shoprenterConfig.clientSecret);
|
|
|
|
|
- if (!validation.valid) {
|
|
|
|
|
- console.error('ShopRenter callback HMAC validation failed:', validation.error);
|
|
|
|
|
- return res.status(403).json({
|
|
|
|
|
- error: 'Invalid request signature',
|
|
|
|
|
- details: validation.error
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Validate required parameters
|
|
|
|
|
- if (!shopname || !code || !app_url) {
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Missing required parameters',
|
|
|
|
|
- details: 'shopname, code, and app_url are required'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Verify state/nonce if provided
|
|
|
|
|
- if (state) {
|
|
|
|
|
- const nonceData = nonceStore.get(state);
|
|
|
|
|
- if (!nonceData || nonceData.shopname !== shopname) {
|
|
|
|
|
- console.error('Invalid or expired state parameter');
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Invalid state',
|
|
|
|
|
- details: 'Invalid or expired state parameter'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- // Clean up the used nonce
|
|
|
|
|
- nonceStore.delete(state);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`Processing ShopRenter OAuth callback for shop: ${shopname}`);
|
|
|
|
|
-
|
|
|
|
|
- // Exchange authorization code for access token
|
|
|
|
|
- const tokenUrl = `${app_url}/admin/oauth/token`;
|
|
|
|
|
- const tokenParams = new URLSearchParams({
|
|
|
|
|
- grant_type: 'authorization_code',
|
|
|
|
|
- client_id: shoprenterConfig.clientId,
|
|
|
|
|
- client_secret: shoprenterConfig.clientSecret,
|
|
|
|
|
- code: code,
|
|
|
|
|
- redirect_uri: shoprenterConfig.redirectUri
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const tokenResponse = await axios.post(tokenUrl, tokenParams.toString(), {
|
|
|
|
|
- headers: {
|
|
|
|
|
- 'Content-Type': 'application/x-www-form-urlencoded'
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const tokenData = tokenResponse.data;
|
|
|
|
|
-
|
|
|
|
|
- if (!tokenData.access_token) {
|
|
|
|
|
- throw new Error('No access token received from ShopRenter');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`Successfully received ShopRenter access token for: ${shopname}`);
|
|
|
|
|
-
|
|
|
|
|
- // Calculate token expiration
|
|
|
|
|
- const expiresAt = tokenData.expires_in
|
|
|
|
|
- ? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
|
|
|
|
|
- : null;
|
|
|
|
|
-
|
|
|
|
|
- // Store the store information in the stores table
|
|
|
|
|
- const { data: storeData, error: storeError } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .insert({
|
|
|
|
|
- platform_name: 'shoprenter',
|
|
|
|
|
- store_name: shopname,
|
|
|
|
|
- store_url: app_url,
|
|
|
|
|
- scopes: tokenData.scope ? tokenData.scope.split(' ') : shoprenterConfig.scopes.split(' '),
|
|
|
|
|
- alt_data: {
|
|
|
|
|
- shopname: shopname,
|
|
|
|
|
- token_type: tokenData.token_type
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- .select()
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (storeError) {
|
|
|
|
|
- console.error('Error storing ShopRenter store data:', storeError);
|
|
|
|
|
- return res.status(500).json({
|
|
|
|
|
- error: 'Failed to store store data',
|
|
|
|
|
- details: storeError.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Store the ShopRenter tokens in shoprenter_tokens table
|
|
|
|
|
- const { data: tokenRecord, error: tokenError } = await supabase
|
|
|
|
|
- .from('shoprenter_tokens')
|
|
|
|
|
- .insert({
|
|
|
|
|
- store_id: storeData.id,
|
|
|
|
|
- access_token: tokenData.access_token,
|
|
|
|
|
- refresh_token: tokenData.refresh_token || null,
|
|
|
|
|
- expires_at: expiresAt,
|
|
|
|
|
- scopes: tokenData.scope ? tokenData.scope.split(' ') : shoprenterConfig.scopes.split(' '),
|
|
|
|
|
- shopname: shopname,
|
|
|
|
|
- shop_domain: app_url,
|
|
|
|
|
- is_active: true
|
|
|
|
|
- })
|
|
|
|
|
- .select()
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (tokenError) {
|
|
|
|
|
- console.error('Error storing ShopRenter token:', tokenError);
|
|
|
|
|
- // Try to clean up the store record
|
|
|
|
|
- await supabase.from('stores').delete().eq('id', storeData.id);
|
|
|
|
|
- return res.status(500).json({
|
|
|
|
|
- error: 'Failed to store token data',
|
|
|
|
|
- details: tokenError.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`Successfully stored ShopRenter connection for: ${shopname}`);
|
|
|
|
|
-
|
|
|
|
|
- // Redirect to dashboard
|
|
|
|
|
- res.redirect(`${FRONTEND_URL}/dashboard?connection=success&platform=shoprenter`);
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('ShopRenter OAuth callback error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Failed to complete OAuth process',
|
|
|
|
|
- details: error.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Route 3: Handle ShopRenter App Uninstall
|
|
|
|
|
-app.get('/auth/shoprenter/uninstall', verifyShopRenterRequest, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { shopname, app_url, timestamp } = req.query;
|
|
|
|
|
-
|
|
|
|
|
- if (!shopname || !app_url) {
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Missing required parameters',
|
|
|
|
|
- details: 'shopname and app_url are required'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`ShopRenter uninstall request for shop: ${shopname}`);
|
|
|
|
|
-
|
|
|
|
|
- // Deactivate the store tokens
|
|
|
|
|
- const { data: updatedTokens, error: tokenError } = await supabase
|
|
|
|
|
- .from('shoprenter_tokens')
|
|
|
|
|
- .update({ is_active: false })
|
|
|
|
|
- .eq('shopname', shopname)
|
|
|
|
|
- .eq('shop_domain', app_url);
|
|
|
|
|
-
|
|
|
|
|
- if (tokenError) {
|
|
|
|
|
- console.error('Error deactivating ShopRenter tokens:', tokenError);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Deactivate the store
|
|
|
|
|
- const { data: updatedStore, error: storeError } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .update({ is_active: false })
|
|
|
|
|
- .eq('platform_name', 'shoprenter')
|
|
|
|
|
- .eq('store_url', app_url);
|
|
|
|
|
-
|
|
|
|
|
- if (storeError) {
|
|
|
|
|
- console.error('Error deactivating ShopRenter store:', storeError);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`Successfully processed uninstall for: ${shopname}`);
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'App uninstalled successfully'
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('ShopRenter uninstall error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Failed to process uninstall',
|
|
|
|
|
- details: error.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Route 2: Handle OAuth callback
|
|
|
|
|
-app.get('/auth/shopify/callback', async (req, res) => {
|
|
|
|
|
- const { shop, code, state } = req.query;
|
|
|
|
|
-
|
|
|
|
|
- // Validate required parameters
|
|
|
|
|
- if (!shop || !code || !state) {
|
|
|
|
|
- console.error('Missing required parameters in callback');
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Missing required parameters',
|
|
|
|
|
- details: 'Shop, code, and state parameters are required'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Validate shop URL
|
|
|
|
|
- const normalizedShop = normalizeShopUrl(shop);
|
|
|
|
|
- if (!isValidShopUrl(normalizedShop)) {
|
|
|
|
|
- console.error(`Invalid shop URL in callback: ${shop}`);
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Invalid shop URL',
|
|
|
|
|
- details: 'Invalid shop URL provided in callback'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Verify nonce/state
|
|
|
|
|
- const nonceData = nonceStore.get(state);
|
|
|
|
|
- if (!nonceData || nonceData.shop !== normalizedShop) {
|
|
|
|
|
- console.error('Invalid or expired state parameter');
|
|
|
|
|
- return res.status(400).json({
|
|
|
|
|
- error: 'Invalid state',
|
|
|
|
|
- details: 'Invalid or expired state parameter'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const tokenRes = await fetch(`https://${normalizedShop}/admin/oauth/access_token`, {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
- body: JSON.stringify({
|
|
|
|
|
- client_id: config.apiKey,
|
|
|
|
|
- client_secret: config.apiSecret,
|
|
|
|
|
- code,
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- if (!tokenRes.ok) {
|
|
|
|
|
- throw new Error(`Token request failed: ${tokenRes.statusText}`);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const tokenJson = await tokenRes.json();
|
|
|
|
|
-
|
|
|
|
|
- console.log("tokenJson", tokenJson);
|
|
|
|
|
-
|
|
|
|
|
- // Clean up the used nonce
|
|
|
|
|
- nonceStore.delete(state);
|
|
|
|
|
-
|
|
|
|
|
- // TODO: Save tokenJson.access_token securely
|
|
|
|
|
- console.log(`Successfully authenticated shop: ${normalizedShop}`);
|
|
|
|
|
-
|
|
|
|
|
- res.redirect(`${FRONTEND_URL}/`);
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('OAuth callback error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Authentication failed',
|
|
|
|
|
- details: 'Failed to complete OAuth process'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Route 3: Test API call with stored token
|
|
|
|
|
-app.get('/api/products/:shop', async (req, res) => {
|
|
|
|
|
- const { shop } = req.params;
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- // TODO: Retrieve access token from database
|
|
|
|
|
- // const accessToken = await getStoredToken(shop);
|
|
|
|
|
- const accessToken = 'your_stored_access_token';
|
|
|
|
|
-
|
|
|
|
|
- const response = await axios.get(`https://${shop}/admin/api/2023-10/products.json`, {
|
|
|
|
|
- headers: {
|
|
|
|
|
- 'X-Shopify-Access-Token': accessToken
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- res.json(response.data);
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('API call error:', error.response?.data || error.message);
|
|
|
|
|
- res.status(500).json({ error: 'Failed to fetch products' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Webhook verification middleware
|
|
|
|
|
-const verifyWebhook = (req, res, next) => {
|
|
|
|
|
- const shopifyHmac = req.headers['x-shopify-hmac-sha256'];
|
|
|
|
|
- const rawBody = req.body;
|
|
|
|
|
-
|
|
|
|
|
- if (!shopifyHmac || !rawBody) {
|
|
|
|
|
- console.warn('❌ Missing HMAC or body in webhook request');
|
|
|
|
|
- return res.status(401).send('Unauthorized');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- // Ensure rawBody is a Buffer
|
|
|
|
|
- const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
|
|
|
|
|
-
|
|
|
|
|
- const calculatedHmacDigest = crypto
|
|
|
|
|
- .createHmac('sha256', config.apiSecret)
|
|
|
|
|
- .update(bodyBuffer)
|
|
|
|
|
- .digest('base64');
|
|
|
|
|
-
|
|
|
|
|
- const hmacValid = crypto.timingSafeEqual(
|
|
|
|
|
- Buffer.from(calculatedHmacDigest),
|
|
|
|
|
- Buffer.from(shopifyHmac)
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- if (!hmacValid) {
|
|
|
|
|
- console.warn('❌ Invalid webhook HMAC');
|
|
|
|
|
- return res.status(401).send('Unauthorized');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // HMAC is valid, proceed to next middleware
|
|
|
|
|
- next();
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('❌ Webhook verification error:', error);
|
|
|
|
|
- return res.status(401).send('Unauthorized');
|
|
|
|
|
- }
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to process webhook data
|
|
|
|
|
-const processWebhook = async (req, res, topic) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const shop = req.headers['x-shopify-shop-domain'];
|
|
|
|
|
-
|
|
|
|
|
- // Parse the raw body as JSON
|
|
|
|
|
- let data;
|
|
|
|
|
- if (Buffer.isBuffer(req.body)) {
|
|
|
|
|
- data = JSON.parse(req.body.toString('utf8'));
|
|
|
|
|
- } else {
|
|
|
|
|
- data = req.body;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`✅ Webhook received: ${topic} from ${shop}`);
|
|
|
|
|
-
|
|
|
|
|
- // Respond quickly to Shopify (within 1 second)
|
|
|
|
|
- res.status(200).send('Webhook received');
|
|
|
|
|
-
|
|
|
|
|
- // Process webhook data asynchronously
|
|
|
|
|
- // TODO: Implement your business logic here
|
|
|
|
|
- // Example: Queue the webhook for processing
|
|
|
|
|
- // await queueWebhook(shop, topic, data);
|
|
|
|
|
-
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- console.error(`❌ Webhook processing error for ${topic}:`, err.message);
|
|
|
|
|
- // Still respond with 200 to prevent retries
|
|
|
|
|
- res.status(200).send('Webhook received');
|
|
|
|
|
- }
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// GDPR Webhooks - using the correct paths that Shopify expects
|
|
|
|
|
-app.post('/gdpr/customers-data-request', verifyWebhook, (req, res) => {
|
|
|
|
|
- processWebhook(req, res, 'customers/data_request');
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-app.post('/gdpr/customers-redact', verifyWebhook, (req, res) => {
|
|
|
|
|
- processWebhook(req, res, 'customers/redact');
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-app.post('/gdpr/shop-redact', verifyWebhook, (req, res) => {
|
|
|
|
|
- processWebhook(req, res, 'shop/redact');
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to format time ago
|
|
|
|
|
-const getTimeAgo = (date) => {
|
|
|
|
|
- const now = new Date();
|
|
|
|
|
- const past = new Date(date);
|
|
|
|
|
- const diffInSeconds = Math.floor((now - past) / 1000);
|
|
|
|
|
-
|
|
|
|
|
- if (diffInSeconds < 60) return `${diffInSeconds} seconds ago`;
|
|
|
|
|
- if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} mins ago`;
|
|
|
|
|
- if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
|
|
|
|
|
- return `${Math.floor(diffInSeconds / 86400)} days ago`;
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to format duration
|
|
|
|
|
-const formatDuration = (seconds) => {
|
|
|
|
|
- if (!seconds) return "0:00";
|
|
|
|
|
- const minutes = Math.floor(seconds / 60);
|
|
|
|
|
- const remainingSeconds = seconds % 60;
|
|
|
|
|
- return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to mask phone number
|
|
|
|
|
-const maskPhoneNumber = (phone) => {
|
|
|
|
|
- if (!phone) return "...xxx-0000";
|
|
|
|
|
- const last4 = phone.slice(-4);
|
|
|
|
|
- return `...xxx-${last4}`;
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to determine sentiment
|
|
|
|
|
-const getSentiment = (summary) => {
|
|
|
|
|
- if (!summary) return "Neutral";
|
|
|
|
|
- const lowerSummary = summary.toLowerCase();
|
|
|
|
|
- if (lowerSummary.includes("interested") || lowerSummary.includes("positive")) return "Positive";
|
|
|
|
|
- if (lowerSummary.includes("not interested") || lowerSummary.includes("negative")) return "Negative";
|
|
|
|
|
- return "Neutral";
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to determine intent
|
|
|
|
|
-const getIntent = (transcript) => {
|
|
|
|
|
- if (!transcript) return "General Support";
|
|
|
|
|
- const lowerTranscript = transcript.toLowerCase();
|
|
|
|
|
- if (lowerTranscript.includes("order") || lowerTranscript.includes("status")) return "Order Status";
|
|
|
|
|
- if (lowerTranscript.includes("return") || lowerTranscript.includes("refund")) return "Return Request";
|
|
|
|
|
- if (lowerTranscript.includes("product") || lowerTranscript.includes("item")) return "Product Information";
|
|
|
|
|
- if (lowerTranscript.includes("shipping") || lowerTranscript.includes("delivery")) return "Shipping Inquiry";
|
|
|
|
|
- return "General Support";
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-//VAPI Routes
|
|
|
|
|
-
|
|
|
|
|
-// Get dashboard statistics
|
|
|
|
|
-app.get('/api/dashboard/stats', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const now = new Date();
|
|
|
|
|
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
|
- const yesterday = new Date(today);
|
|
|
|
|
- yesterday.setDate(yesterday.getDate() - 1);
|
|
|
|
|
-
|
|
|
|
|
- // Get all calls for the user
|
|
|
|
|
- const { data: allCalls, error: allCallsError } = await supabase
|
|
|
|
|
- .from('call_logs')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('user_id', req.user.id);
|
|
|
|
|
-
|
|
|
|
|
- if (allCallsError) {
|
|
|
|
|
- console.error('Error fetching calls for stats:', allCallsError);
|
|
|
|
|
- return res.status(500).json({ error: 'Failed to fetch dashboard stats' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Get today's calls
|
|
|
|
|
- const todayCalls = allCalls.filter(call => new Date(call.created_at) >= today);
|
|
|
|
|
- const yesterdayCalls = allCalls.filter(call =>
|
|
|
|
|
- new Date(call.created_at) >= yesterday && new Date(call.created_at) < today
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // Calculate KPIs
|
|
|
|
|
- const totalCalls = todayCalls.length;
|
|
|
|
|
- const totalCallsYesterday = yesterdayCalls.length;
|
|
|
|
|
- const totalCallsChange = totalCallsYesterday > 0
|
|
|
|
|
- ? ((totalCalls - totalCallsYesterday) / totalCallsYesterday * 100).toFixed(1)
|
|
|
|
|
- : totalCalls > 0 ? '+100.0' : '0.0';
|
|
|
|
|
-
|
|
|
|
|
- const resolvedCalls = todayCalls.filter(c =>
|
|
|
|
|
- c.call_outcome === 'resolved' || c.call_outcome === 'interested'
|
|
|
|
|
- ).length;
|
|
|
|
|
- const resolvedCallsYesterday = yesterdayCalls.filter(c =>
|
|
|
|
|
- c.call_outcome === 'resolved' || c.call_outcome === 'interested'
|
|
|
|
|
- ).length;
|
|
|
|
|
- const resolvedCallsChange = resolvedCallsYesterday > 0
|
|
|
|
|
- ? ((resolvedCalls - resolvedCallsYesterday) / resolvedCallsYesterday * 100).toFixed(1)
|
|
|
|
|
- : resolvedCalls > 0 ? '+100.0' : '0.0';
|
|
|
|
|
-
|
|
|
|
|
- // Calculate average call duration
|
|
|
|
|
- const completedCalls = todayCalls.filter(c => c.duration_seconds && c.duration_seconds > 0);
|
|
|
|
|
- const avgDuration = completedCalls.length > 0
|
|
|
|
|
- ? Math.floor(completedCalls.reduce((sum, c) => sum + c.duration_seconds, 0) / completedCalls.length)
|
|
|
|
|
- : 0;
|
|
|
|
|
-
|
|
|
|
|
- const completedCallsYesterday = yesterdayCalls.filter(c => c.duration_seconds && c.duration_seconds > 0);
|
|
|
|
|
- const avgDurationYesterday = completedCallsYesterday.length > 0
|
|
|
|
|
- ? Math.floor(completedCallsYesterday.reduce((sum, c) => sum + c.duration_seconds, 0) / completedCallsYesterday.length)
|
|
|
|
|
- : 0;
|
|
|
|
|
- const avgDurationChange = avgDurationYesterday > 0
|
|
|
|
|
- ? ((avgDuration - avgDurationYesterday) / avgDurationYesterday * 100).toFixed(1)
|
|
|
|
|
- : '0.0';
|
|
|
|
|
-
|
|
|
|
|
- // Calculate total cost
|
|
|
|
|
- const totalCost = todayCalls.reduce((sum, c) => sum + (c.cost_total || 0), 0);
|
|
|
|
|
- const totalCostYesterday = yesterdayCalls.reduce((sum, c) => sum + (c.cost_total || 0), 0);
|
|
|
|
|
- const totalCostChange = totalCostYesterday > 0
|
|
|
|
|
- ? ((totalCost - totalCostYesterday) / totalCostYesterday * 100).toFixed(1)
|
|
|
|
|
- : totalCost > 0 ? '+100.0' : '0.0';
|
|
|
|
|
-
|
|
|
|
|
- // Calculate time saved (assuming avg human call is 5 minutes)
|
|
|
|
|
- const timeSavedSeconds = completedCalls.length * 5 * 60;
|
|
|
|
|
- const timeSavedHours = (timeSavedSeconds / 3600).toFixed(1);
|
|
|
|
|
- const timeSavedSecondsYesterday = completedCallsYesterday.length * 5 * 60;
|
|
|
|
|
- const timeSavedHoursYesterday = (timeSavedSecondsYesterday / 3600).toFixed(1);
|
|
|
|
|
- const timeSavedChange = timeSavedHoursYesterday > 0
|
|
|
|
|
- ? ((timeSavedHours - timeSavedHoursYesterday) / timeSavedHoursYesterday * 100).toFixed(1)
|
|
|
|
|
- : timeSavedHours > 0 ? '+100.0' : '0.0';
|
|
|
|
|
-
|
|
|
|
|
- // Calculate saved on human costs (assuming $32/hour for human agent)
|
|
|
|
|
- const humanCostSaved = (timeSavedSeconds / 3600) * 32;
|
|
|
|
|
- const humanCostSavedYesterday = (timeSavedSecondsYesterday / 3600) * 32;
|
|
|
|
|
- const humanCostSavedChange = humanCostSavedYesterday > 0
|
|
|
|
|
- ? ((humanCostSaved - humanCostSavedYesterday) / humanCostSavedYesterday * 100).toFixed(1)
|
|
|
|
|
- : humanCostSaved > 0 ? '+100.0' : '0.0';
|
|
|
|
|
-
|
|
|
|
|
- // Calculate resolution rate
|
|
|
|
|
- const resolutionRate = totalCalls > 0
|
|
|
|
|
- ? ((resolvedCalls / totalCalls) * 100).toFixed(1)
|
|
|
|
|
- : 0;
|
|
|
|
|
- const resolutionRateYesterday = totalCallsYesterday > 0
|
|
|
|
|
- ? ((resolvedCallsYesterday / totalCallsYesterday) * 100).toFixed(1)
|
|
|
|
|
- : 0;
|
|
|
|
|
- const dailyChange = resolutionRateYesterday > 0
|
|
|
|
|
- ? (resolutionRate - resolutionRateYesterday).toFixed(1)
|
|
|
|
|
- : '0.0';
|
|
|
|
|
-
|
|
|
|
|
- // Calculate call intents (top 5)
|
|
|
|
|
- const intentCounts = {};
|
|
|
|
|
- todayCalls.forEach(call => {
|
|
|
|
|
- const intent = getIntent(call.transcript) || 'General Support';
|
|
|
|
|
- intentCounts[intent] = (intentCounts[intent] || 0) + 1;
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const yesterdayIntentCounts = {};
|
|
|
|
|
- yesterdayCalls.forEach(call => {
|
|
|
|
|
- const intent = getIntent(call.transcript) || 'General Support';
|
|
|
|
|
- yesterdayIntentCounts[intent] = (yesterdayIntentCounts[intent] || 0) + 1;
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const topIntents = Object.entries(intentCounts)
|
|
|
|
|
- .map(([name, count]) => {
|
|
|
|
|
- const yesterdayCount = yesterdayIntentCounts[name] || 0;
|
|
|
|
|
- const change = yesterdayCount > 0
|
|
|
|
|
- ? ((count - yesterdayCount) / yesterdayCount * 100).toFixed(1)
|
|
|
|
|
- : count > 0 ? '+100.0' : '0.0';
|
|
|
|
|
- const percentage = totalCalls > 0 ? ((count / totalCalls) * 100).toFixed(1) : 0;
|
|
|
|
|
- return {
|
|
|
|
|
- name,
|
|
|
|
|
- count,
|
|
|
|
|
- percentage: parseFloat(percentage),
|
|
|
|
|
- change: change > 0 ? `+${change}%` : `${change}%`
|
|
|
|
|
- };
|
|
|
|
|
- })
|
|
|
|
|
- .sort((a, b) => b.count - a.count)
|
|
|
|
|
- .slice(0, 5);
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- stats: {
|
|
|
|
|
- totalCalls: {
|
|
|
|
|
- value: totalCalls,
|
|
|
|
|
- change: totalCallsChange > 0 ? `+${totalCallsChange}%` : `${totalCallsChange}%`,
|
|
|
|
|
- changeType: totalCallsChange >= 0 ? 'positive' : 'negative'
|
|
|
|
|
- },
|
|
|
|
|
- resolvedCalls: {
|
|
|
|
|
- value: resolvedCalls,
|
|
|
|
|
- change: resolvedCallsChange > 0 ? `+${resolvedCallsChange}%` : `${resolvedCallsChange}%`,
|
|
|
|
|
- changeType: resolvedCallsChange >= 0 ? 'positive' : 'negative'
|
|
|
|
|
- },
|
|
|
|
|
- avgDuration: {
|
|
|
|
|
- value: avgDuration,
|
|
|
|
|
- formatted: formatDuration(avgDuration),
|
|
|
|
|
- change: avgDurationChange > 0 ? `+${avgDurationChange}%` : `${avgDurationChange}%`,
|
|
|
|
|
- changeType: avgDurationChange <= 0 ? 'positive' : 'negative' // Lower duration is better
|
|
|
|
|
- },
|
|
|
|
|
- totalCost: {
|
|
|
|
|
- value: totalCost,
|
|
|
|
|
- formatted: `$${totalCost.toFixed(2)}`,
|
|
|
|
|
- change: totalCostChange > 0 ? `+${totalCostChange}%` : `${totalCostChange}%`,
|
|
|
|
|
- changeType: totalCostChange >= 0 ? 'positive' : 'negative'
|
|
|
|
|
- },
|
|
|
|
|
- timeSaved: {
|
|
|
|
|
- value: timeSavedHours,
|
|
|
|
|
- formatted: `${timeSavedHours}h`,
|
|
|
|
|
- change: timeSavedChange > 0 ? `+${timeSavedChange}%` : `${timeSavedChange}%`,
|
|
|
|
|
- changeType: timeSavedChange >= 0 ? 'positive' : 'negative'
|
|
|
|
|
- },
|
|
|
|
|
- humanCostSaved: {
|
|
|
|
|
- value: humanCostSaved,
|
|
|
|
|
- formatted: `$${humanCostSaved.toFixed(0)}`,
|
|
|
|
|
- change: humanCostSavedChange > 0 ? `+${humanCostSavedChange}%` : `${humanCostSavedChange}%`,
|
|
|
|
|
- changeType: humanCostSavedChange >= 0 ? 'positive' : 'negative'
|
|
|
|
|
- },
|
|
|
|
|
- resolutionRate: {
|
|
|
|
|
- value: parseFloat(resolutionRate),
|
|
|
|
|
- dailyChange: dailyChange > 0 ? `+${dailyChange}%` : `${dailyChange}%`,
|
|
|
|
|
- weeklyChange: '+0.0%' // TODO: Calculate actual weekly change
|
|
|
|
|
- },
|
|
|
|
|
- topIntents
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Dashboard stats error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Get call logs endpoint with securedSession middleware
|
|
|
|
|
-app.get('/api/call-logs', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- // Get call logs for the authenticated user
|
|
|
|
|
- const { data: callLogs, error: logsError } = await supabase
|
|
|
|
|
- .from('call_logs')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .order('created_at', { ascending: false });
|
|
|
|
|
-
|
|
|
|
|
- if (logsError) {
|
|
|
|
|
- console.error('Error fetching call logs:', logsError);
|
|
|
|
|
- return res.status(500).json({ error: 'Failed to fetch call logs' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Transform the data into the required format
|
|
|
|
|
- const transformedLogs = callLogs.map(log => {
|
|
|
|
|
- const outcome = log.call_outcome || "pending";
|
|
|
|
|
- const sentiment = getSentiment(log.summary);
|
|
|
|
|
- const intent = getIntent(log.transcript);
|
|
|
|
|
-
|
|
|
|
|
- // Helper to format ENUM values for display (e.g., "not_interested" -> "Not Interested")
|
|
|
|
|
- const formatOutcome = (outcome) => {
|
|
|
|
|
- return outcome
|
|
|
|
|
- .split('_')
|
|
|
|
|
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
|
|
|
- .join(' ');
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // Determine outcome color based on ENUM value
|
|
|
|
|
- const getOutcomeColor = (outcome) => {
|
|
|
|
|
- switch(outcome) {
|
|
|
|
|
- case 'resolved':
|
|
|
|
|
- case 'interested':
|
|
|
|
|
- return 'text-green-500';
|
|
|
|
|
- case 'not_interested':
|
|
|
|
|
- case 'no_answer':
|
|
|
|
|
- case 'busy':
|
|
|
|
|
- case 'false':
|
|
|
|
|
- return 'text-red-500';
|
|
|
|
|
- case 'potential':
|
|
|
|
|
- case 'callback_requested':
|
|
|
|
|
- case 'voicemail':
|
|
|
|
|
- return 'text-yellow-500';
|
|
|
|
|
- default:
|
|
|
|
|
- return 'text-slate-400';
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- time: getTimeAgo(log.created_at),
|
|
|
|
|
- customer: (log.customer_number),
|
|
|
|
|
- intent: intent,
|
|
|
|
|
- outcome: formatOutcome(outcome),
|
|
|
|
|
- duration: formatDuration(log.duration_seconds),
|
|
|
|
|
- sentiment: sentiment,
|
|
|
|
|
- cost: `$${log.cost_total?.toFixed(2) || "0.00"}`,
|
|
|
|
|
- outcomeColor: getOutcomeColor(outcome),
|
|
|
|
|
- sentimentColor: sentiment === "Positive" ? "text-green-500" :
|
|
|
|
|
- sentiment === "Negative" ? "text-red-500" :
|
|
|
|
|
- "text-yellow-500"
|
|
|
|
|
- };
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- call_logs: transformedLogs
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Get call logs error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Store Management Routes
|
|
|
|
|
-
|
|
|
|
|
-// Get all stores for the authenticated user
|
|
|
|
|
-app.get('/api/stores', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { data: stores, error } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .eq('is_active', true)
|
|
|
|
|
- .order('created_at', { ascending: false });
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- console.error('Error fetching stores:', error);
|
|
|
|
|
- return res.status(500).json({ error: 'Failed to fetch stores' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- stores: stores
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Get stores error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Get a specific store by ID
|
|
|
|
|
-app.get('/api/stores/:id', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { id } = req.params;
|
|
|
|
|
-
|
|
|
|
|
- const { data: store, error } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('id', id)
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .eq('is_active', true)
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- console.error('Error fetching store:', error);
|
|
|
|
|
- return res.status(404).json({ error: 'Store not found' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- store: store
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Get store error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Update a store
|
|
|
|
|
-app.put('/api/stores/:id', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { id } = req.params;
|
|
|
|
|
- const updateData = req.body;
|
|
|
|
|
-
|
|
|
|
|
- // Remove sensitive fields that shouldn't be updated via API
|
|
|
|
|
- delete updateData.user_id;
|
|
|
|
|
- delete updateData.id;
|
|
|
|
|
- delete updateData.created_at;
|
|
|
|
|
-
|
|
|
|
|
- const { data: store, error } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .update({
|
|
|
|
|
- ...updateData,
|
|
|
|
|
- updated_at: new Date().toISOString()
|
|
|
|
|
- })
|
|
|
|
|
- .eq('id', id)
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .select()
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- console.error('Error updating store:', error);
|
|
|
|
|
- return res.status(400).json({ error: 'Failed to update store' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- store: store
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Update store error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Delete a store (soft delete by setting is_active to false)
|
|
|
|
|
-app.delete('/api/stores/:id', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { id } = req.params;
|
|
|
|
|
-
|
|
|
|
|
- const { data: store, error } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .update({
|
|
|
|
|
- is_active: false,
|
|
|
|
|
- updated_at: new Date().toISOString()
|
|
|
|
|
- })
|
|
|
|
|
- .eq('id', id)
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .select()
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- console.error('Error deleting store:', error);
|
|
|
|
|
- return res.status(400).json({ error: 'Failed to delete store' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'Store deleted successfully',
|
|
|
|
|
- store: store
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Delete store error:', error);
|
|
|
|
|
- res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// ShopRenter API Integration Functions
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to get active ShopRenter token for a store
|
|
|
|
|
-async function getShopRenterToken(storeId) {
|
|
|
|
|
- const { data: tokenData, error } = await supabase
|
|
|
|
|
- .from('shoprenter_tokens')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('store_id', storeId)
|
|
|
|
|
- .eq('is_active', true)
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (error || !tokenData) {
|
|
|
|
|
- throw new Error('No active ShopRenter token found for store');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Check if token is expired
|
|
|
|
|
- if (tokenData.expires_at) {
|
|
|
|
|
- const expiryDate = new Date(tokenData.expires_at);
|
|
|
|
|
- if (expiryDate < new Date()) {
|
|
|
|
|
- // Token expired, try to refresh
|
|
|
|
|
- if (tokenData.refresh_token) {
|
|
|
|
|
- return await refreshShopRenterToken(tokenData);
|
|
|
|
|
- } else {
|
|
|
|
|
- throw new Error('ShopRenter token expired and no refresh token available');
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return tokenData;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to refresh ShopRenter access token
|
|
|
|
|
-async function refreshShopRenterToken(tokenData) {
|
|
|
|
|
- try {
|
|
|
|
|
- const tokenUrl = `${tokenData.shop_domain}/admin/oauth/token`;
|
|
|
|
|
- const params = new URLSearchParams({
|
|
|
|
|
- grant_type: 'refresh_token',
|
|
|
|
|
- client_id: shoprenterConfig.clientId,
|
|
|
|
|
- client_secret: shoprenterConfig.clientSecret,
|
|
|
|
|
- refresh_token: tokenData.refresh_token
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const response = await axios.post(tokenUrl, params.toString(), {
|
|
|
|
|
- headers: {
|
|
|
|
|
- 'Content-Type': 'application/x-www-form-urlencoded'
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const newTokenData = response.data;
|
|
|
|
|
-
|
|
|
|
|
- // Update token in database
|
|
|
|
|
- const expiresAt = newTokenData.expires_in
|
|
|
|
|
- ? new Date(Date.now() + newTokenData.expires_in * 1000).toISOString()
|
|
|
|
|
- : null;
|
|
|
|
|
-
|
|
|
|
|
- const { data: updatedToken, error } = await supabase
|
|
|
|
|
- .from('shoprenter_tokens')
|
|
|
|
|
- .update({
|
|
|
|
|
- access_token: newTokenData.access_token,
|
|
|
|
|
- refresh_token: newTokenData.refresh_token || tokenData.refresh_token,
|
|
|
|
|
- expires_at: expiresAt,
|
|
|
|
|
- updated_at: new Date().toISOString()
|
|
|
|
|
- })
|
|
|
|
|
- .eq('id', tokenData.id)
|
|
|
|
|
- .select()
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (error) {
|
|
|
|
|
- throw new Error('Failed to update refreshed token');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return updatedToken;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Token refresh error:', error);
|
|
|
|
|
- throw new Error('Failed to refresh ShopRenter token');
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Helper function to make ShopRenter API requests
|
|
|
|
|
-async function makeShopRenterRequest(tokenData, endpoint, method = 'GET', data = null) {
|
|
|
|
|
- try {
|
|
|
|
|
- const url = `${tokenData.shop_domain}/admin/api${endpoint}`;
|
|
|
|
|
- const config = {
|
|
|
|
|
- method: method,
|
|
|
|
|
- url: url,
|
|
|
|
|
- headers: {
|
|
|
|
|
- 'Authorization': `Bearer ${tokenData.access_token}`,
|
|
|
|
|
- 'Content-Type': 'application/json'
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
|
|
|
- config.data = data;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const response = await axios(config);
|
|
|
|
|
- return response.data;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('ShopRenter API request error:', error.response?.data || error.message);
|
|
|
|
|
- throw error;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Get ShopRenter products for a store
|
|
|
|
|
-app.get('/api/shoprenter/products/:storeId', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { storeId } = req.params;
|
|
|
|
|
-
|
|
|
|
|
- // Verify store belongs to user
|
|
|
|
|
- const { data: store, error: storeError } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('id', storeId)
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .eq('platform_name', 'shoprenter')
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (storeError || !store) {
|
|
|
|
|
- return res.status(404).json({ error: 'Store not found' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Get ShopRenter token
|
|
|
|
|
- const tokenData = await getShopRenterToken(storeId);
|
|
|
|
|
-
|
|
|
|
|
- // Fetch products from ShopRenter API
|
|
|
|
|
- const products = await makeShopRenterRequest(tokenData, '/products');
|
|
|
|
|
-
|
|
|
|
|
- // Update cache
|
|
|
|
|
- if (products && products.items) {
|
|
|
|
|
- for (const product of products.items) {
|
|
|
|
|
- await supabase
|
|
|
|
|
- .from('shoprenter_products_cache')
|
|
|
|
|
- .upsert({
|
|
|
|
|
- store_id: storeId,
|
|
|
|
|
- shoprenter_product_id: product.id.toString(),
|
|
|
|
|
- product_data: product,
|
|
|
|
|
- last_synced_at: new Date().toISOString()
|
|
|
|
|
- }, {
|
|
|
|
|
- onConflict: 'store_id,shoprenter_product_id'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- products: products
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Get ShopRenter products error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Failed to fetch products',
|
|
|
|
|
- details: error.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Get ShopRenter orders for a store
|
|
|
|
|
-app.get('/api/shoprenter/orders/:storeId', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { storeId } = req.params;
|
|
|
|
|
-
|
|
|
|
|
- // Verify store belongs to user
|
|
|
|
|
- const { data: store, error: storeError } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('id', storeId)
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .eq('platform_name', 'shoprenter')
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (storeError || !store) {
|
|
|
|
|
- return res.status(404).json({ error: 'Store not found' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Get ShopRenter token
|
|
|
|
|
- const tokenData = await getShopRenterToken(storeId);
|
|
|
|
|
-
|
|
|
|
|
- // Fetch orders from ShopRenter API
|
|
|
|
|
- const orders = await makeShopRenterRequest(tokenData, '/orders');
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- orders: orders
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Get ShopRenter orders error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Failed to fetch orders',
|
|
|
|
|
- details: error.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Get ShopRenter customers for a store
|
|
|
|
|
-app.get('/api/shoprenter/customers/:storeId', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { storeId } = req.params;
|
|
|
|
|
-
|
|
|
|
|
- // Verify store belongs to user
|
|
|
|
|
- const { data: store, error: storeError } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('id', storeId)
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .eq('platform_name', 'shoprenter')
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (storeError || !store) {
|
|
|
|
|
- return res.status(404).json({ error: 'Store not found' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Get ShopRenter token
|
|
|
|
|
- const tokenData = await getShopRenterToken(storeId);
|
|
|
|
|
-
|
|
|
|
|
- // Fetch customers from ShopRenter API
|
|
|
|
|
- const customers = await makeShopRenterRequest(tokenData, '/customers');
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- customers: customers
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('Get ShopRenter customers error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Failed to fetch customers',
|
|
|
|
|
- details: error.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Sync ShopRenter data (products, orders, customers)
|
|
|
|
|
-app.post('/api/shoprenter/sync/:storeId', securedSession, async (req, res) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const { storeId } = req.params;
|
|
|
|
|
- const { syncType } = req.body; // 'products', 'orders', 'customers', or 'all'
|
|
|
|
|
-
|
|
|
|
|
- // Verify store belongs to user
|
|
|
|
|
- const { data: store, error: storeError } = await supabase
|
|
|
|
|
- .from('stores')
|
|
|
|
|
- .select('*')
|
|
|
|
|
- .eq('id', storeId)
|
|
|
|
|
- .eq('user_id', req.user.id)
|
|
|
|
|
- .eq('platform_name', 'shoprenter')
|
|
|
|
|
- .single();
|
|
|
|
|
-
|
|
|
|
|
- if (storeError || !store) {
|
|
|
|
|
- return res.status(404).json({ error: 'Store not found' });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Get ShopRenter token
|
|
|
|
|
- const tokenData = await getShopRenterToken(storeId);
|
|
|
|
|
-
|
|
|
|
|
- const syncResults = {};
|
|
|
|
|
-
|
|
|
|
|
- // Sync products
|
|
|
|
|
- if (syncType === 'products' || syncType === 'all') {
|
|
|
|
|
- try {
|
|
|
|
|
- const products = await makeShopRenterRequest(tokenData, '/products');
|
|
|
|
|
- if (products && products.items) {
|
|
|
|
|
- for (const product of products.items) {
|
|
|
|
|
- await supabase
|
|
|
|
|
- .from('shoprenter_products_cache')
|
|
|
|
|
- .upsert({
|
|
|
|
|
- store_id: storeId,
|
|
|
|
|
- shoprenter_product_id: product.id.toString(),
|
|
|
|
|
- product_data: product,
|
|
|
|
|
- last_synced_at: new Date().toISOString()
|
|
|
|
|
- }, {
|
|
|
|
|
- onConflict: 'store_id,shoprenter_product_id'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- syncResults.products = { success: true, count: products.items.length };
|
|
|
|
|
- }
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- syncResults.products = { success: false, error: error.message };
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Update last sync time
|
|
|
|
|
- await supabase
|
|
|
|
|
- .from('shoprenter_tokens')
|
|
|
|
|
- .update({ last_sync_at: new Date().toISOString() })
|
|
|
|
|
- .eq('id', tokenData.id);
|
|
|
|
|
-
|
|
|
|
|
- res.json({
|
|
|
|
|
- success: true,
|
|
|
|
|
- message: 'Sync completed',
|
|
|
|
|
- results: syncResults
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('ShopRenter sync error:', error);
|
|
|
|
|
- res.status(500).json({
|
|
|
|
|
- error: 'Failed to sync data',
|
|
|
|
|
- details: error.message
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// Cleanup expired data periodically
|
|
|
|
|
-setInterval(() => {
|
|
|
|
|
- const now = Date.now();
|
|
|
|
|
-
|
|
|
|
|
- // Clean up expired nonces
|
|
|
|
|
- for (const [nonce, data] of nonceStore.entries()) {
|
|
|
|
|
- if (now > data.expiresAt) {
|
|
|
|
|
- nonceStore.delete(nonce);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Clean up expired pending signups
|
|
|
|
|
- for (const [signupId, data] of pendingSignups.entries()) {
|
|
|
|
|
- if (now > data.expiresAt) {
|
|
|
|
|
- pendingSignups.delete(signupId);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}, 5 * 60 * 1000); // Clean up every 5 minutes
|
|
|
|
|
-
|
|
|
|
|
-const PORT = process.env.PORT || 3000;
|
|
|
|
|
-app.listen(PORT, () => {
|
|
|
|
|
- console.log(`Server running on port ${PORT}`);
|
|
|
|
|
- console.log(`Redirect URI: ${config.redirectUri}`);
|
|
|
|
|
- console.log(`OAuth URL: http://localhost:${PORT}/auth/shopify?shop=07u1ra-rp.myshopify.com/`);
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|