Compare commits

...

No commits in common. "856b05a4599f8f69c94db1ba2d95d03bc660ae5f" and "79fdb9a6757464f9e47319f9c3c87c5da1797f39" have entirely different histories.

192 changed files with 5934 additions and 41752 deletions

3
.gitignore vendored
View file

@ -3,5 +3,4 @@ node_modules
npm-debug.log
yarn-error.log
.DS_Store
backend/public/uploads/*
backend/public/*
logs

5
backend/.gitignore vendored
View file

@ -1,5 +0,0 @@
node_modules
.env
npm-debug.log
yarn-error.log
.DS_Store

View file

@ -1,14 +0,0 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN mkdir -p public/uploads/products
COPY . .
EXPOSE 4000
CMD ["npm", "start"]

View file

@ -1,92 +0,0 @@
# E-commerce API Backend
API backend for the Rocks, Bones & Sticks e-commerce platform.
## Setup
```bash
# Install dependencies
npm install
# Run for development
npm run dev
# Run for production
npm start
```
## API Endpoints
### Authentication
- `POST /api/auth/register` - Register a new user
- `POST /api/auth/login-request` - Request a login code
- `POST /api/auth/verify` - Verify login code and generate API key
- `POST /api/auth/verify-key` - Verify an existing API key
- `POST /api/auth/logout` - Logout current user and invalidate API key
For protected routes, include the API key in the request header:
```
X-API-Key: your-api-key-here
```
### Products
- `GET /api/products` - Get all products
- `GET /api/products/:id` - Get single product
- `GET /api/products/categories/all` - Get all categories
- `GET /api/products/tags/all` - Get all tags
- `GET /api/products/category/:categoryName` - Get products by category
### Product Admin (Admin Protected)
These routes require an API key with admin privileges.
- `POST /api/admin/products` - Create a new product with multiple images
- `PUT /api/admin/products/:id` - Update a product
- `DELETE /api/admin/products/:id` - Delete a product
### Cart (Protected)
- `GET /api/cart/:userId` - Get users cart
- `POST /api/cart/add` - Add item to cart
- `PUT /api/cart/update` - Update cart item quantity
- `DELETE /api/cart/clear/:userId` - Clear cart
- `POST /api/cart/checkout` - Checkout (create order from cart)
## Admin Access
By default, the user with email `john@example.com` is set as an admin for testing purposes. The admin status allows access to protected admin routes.
## Environment Variables
Create a `.env` file with the following variables:
```
# Server configuration
PORT=4000
NODE_ENV=development
ENVIRONMENT=beta # Use 'beta' for development, 'prod' for production
# Database connection
DB_HOST=db
DB_USER=postgres
DB_PASSWORD=PLEASECHANGETOSECUREPASSWORD
DB_NAME=ecommerce
DB_PORT=5432
# Email configuration (Postmark)
EMAIL_HOST=smtp.postmarkapp.com
EMAIL_PORT=587
EMAIL_USER=your_postmark_api_token
EMAIL_PASS=your_postmark_api_token
```
### Environment-specific Behavior
Based on the `ENVIRONMENT` variable, the application will use different domain configurations:
- `beta`: Uses `localhost:3000` for the frontend and `http` protocol
- `prod`: Uses `rocks.2many.ca` for the frontend and `https` protocol

View file

@ -1,35 +0,0 @@
{
"name": "rocks-2many-backend",
"version": "1.0.0",
"description": "Backend API for rocks, bones, and sticks e-commerce store",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/client-sqs": "^3.799.0",
"axios": "^1.9.0",
"cors": "^2.8.5",
"csv-parser": "^3.2.0",
"csv-writer": "^1.6.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"ioredis": "^5.6.1",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.2",
"multer-s3": "^3.0.1",
"nodemailer": "^6.9.1",
"pg": "^8.10.0",
"pg-hstore": "^2.3.4",
"slugify": "^1.6.6",
"stripe": "^12.0.0",
"uuid": "^9.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^2.0.22"
}
}

View file

@ -1,204 +0,0 @@
const dotenv = require('dotenv');
// Load environment variables
dotenv.config();
const config = {
// Server configuration
port: process.env.PORT || 4000,
nodeEnv: process.env.NODE_ENV || 'development',
environment: process.env.ENVIRONMENT || 'beta',
// Database configuration
db: {
host: process.env.DB_HOST || 'db',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'ecommerce',
port: process.env.DB_PORT || 5432,
readHost: process.env.DB_READ_HOST || process.env.DB_HOST || 'db',
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '20'),
},
// Email configuration
email: {
host: process.env.EMAIL_HOST || 'smtp.postmarkapp.com',
port: process.env.EMAIL_PORT || 587,
user: process.env.EMAIL_USER || '03c638d8-4a04-9be6',
pass: process.env.EMAIL_PASS || '03c638d8-4a04-9be6',
reply: process.env.EMAIL_REPLY || 'noreply@2many.ca'
},
// Payment configuration
payment: {
stripeEnabled: process.env.STRIPE_ENABLED === 'true',
stripePublicKey: process.env.STRIPE_PUBLIC_KEY || '',
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || ''
},
// Shipping configuration
shipping: {
enabled: process.env.SHIPPING_ENABLED === 'true',
easypostEnabled: process.env.EASYPOST_ENABLED === 'true',
easypostApiKey: process.env.EASYPOST_API_KEY || '',
flatRate: parseFloat(process.env.SHIPPING_FLAT_RATE || '10.00'),
freeThreshold: parseFloat(process.env.SHIPPING_FREE_THRESHOLD || '50.00'),
originAddress: {
street: process.env.SHIPPING_ORIGIN_STREET || '123 Main St',
city: process.env.SHIPPING_ORIGIN_CITY || 'Vancouver',
state: process.env.SHIPPING_ORIGIN_STATE || 'BC',
zip: process.env.SHIPPING_ORIGIN_ZIP || 'V6K 1V6',
country: process.env.SHIPPING_ORIGIN_COUNTRY || 'CA'
},
defaultPackage: {
length: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_LENGTH || '15'),
width: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_WIDTH || '12'),
height: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_HEIGHT || '10'),
unit: process.env.SHIPPING_DEFAULT_PACKAGE_UNIT || 'cm',
weightUnit: process.env.SHIPPING_DEFAULT_WEIGHT_UNIT || 'g'
},
carriersAllowed: (process.env.SHIPPING_CARRIERS_ALLOWED || 'USPS,UPS,FedEx,DHL,Canada Post,Purolator').split(',')
},
// Site configuration (domain and protocol based on environment)
site: {
domain: process.env.ENVIRONMENT === 'prod' ? (process.env.APP_PROD_URL || 'rocks.2many.ca') : 'localhost:3000',
protocol: process.env.ENVIRONMENT === 'prod' ? 'https' : 'http',
apiDomain: process.env.ENVIRONMENT === 'prod' ? (process.env.API_PROD_URL || 'api.rocks.2many.ca') : 'localhost:4000',
analyticApiKey: process.env.SITE_ANALYTIC_API || '',
deployment: process.env.DEPLOYMENT_MODE || 'self-hosted',
redisHost: process.env.REDIS_HOST || '',
redisTLS: process.env.REDIS_TLS || '',
awsRegion: process.env.AWS_REGION || '',
awsS3Bucket: process.env.S3_BUCKET || '',
cdnDomain: process.env.CDN_DOMAIN || '',
awsQueueUrl: process.env.SQS_QUEUE_URL || '',
sessionSecret: process.env.SESSION_SECRET || '',
redisPort: process.env.REDIS_PORT || '',
redisPassword: process.env.REDIS_PASSWORD || ''
}
};
// This function will be called after loading settings from DB
// to override config values with DB values
config.updateFromDatabase = (settings) => {
if (!settings) return;
// Update email settings if they exist in DB
const emailSettings = settings.filter(s => s.category === 'email');
if (emailSettings.length > 0) {
const smtpHost = emailSettings.find(s => s.key === 'smtp_host');
const smtpPort = emailSettings.find(s => s.key === 'smtp_port');
const smtpUser = emailSettings.find(s => s.key === 'smtp_user');
const smtpPass = emailSettings.find(s => s.key === 'smtp_password');
const smtpReply = emailSettings.find(s => s.key === 'smtp_from_email');
if (smtpHost && smtpHost.value) config.email.host = smtpHost.value;
if (smtpPort && smtpPort.value) config.email.port = parseInt(smtpPort.value, 10);
if (smtpUser && smtpUser.value) config.email.user = smtpUser.value;
if (smtpPass && smtpPass.value) config.email.pass = smtpPass.value;
if (smtpReply && smtpReply.value) config.email.reply = smtpReply.value;
}
// Update payment settings if they exist in DB
const paymentSettings = settings.filter(s => s.category === 'payment');
if (paymentSettings.length > 0) {
const stripeEnabled = paymentSettings.find(s => s.key === 'stripe_enabled');
const stripePublic = paymentSettings.find(s => s.key === 'stripe_public_key');
const stripeSecret = paymentSettings.find(s => s.key === 'stripe_secret_key');
const stripeWebhook = paymentSettings.find(s => s.key === 'stripe_webhook_secret');
if (stripeEnabled && stripeEnabled.value) config.payment.stripeEnabled = stripeEnabled.value === 'true';
if (stripePublic && stripePublic.value) config.payment.stripePublicKey = stripePublic.value;
if (stripeSecret && stripeSecret.value) config.payment.stripeSecretKey = stripeSecret.value;
if (stripeWebhook && stripeWebhook.value) config.payment.stripeWebhookSecret = stripeWebhook.value;
}
// Update shipping settings if they exist in DB
const shippingSettings = settings.filter(s => s.category === 'shipping');
if (shippingSettings.length > 0) {
const shippingEnabled = shippingSettings.find(s => s.key === 'shipping_enabled');
const easypostEnabled = shippingSettings.find(s => s.key === 'easypost_enabled');
const easypostApiKey = shippingSettings.find(s => s.key === 'easypost_api_key');
const flatRate = shippingSettings.find(s => s.key === 'shipping_flat_rate');
const freeThreshold = shippingSettings.find(s => s.key === 'shipping_free_threshold');
const originStreet = shippingSettings.find(s => s.key === 'shipping_origin_street');
const originCity = shippingSettings.find(s => s.key === 'shipping_origin_city');
const originState = shippingSettings.find(s => s.key === 'shipping_origin_state');
const originZip = shippingSettings.find(s => s.key === 'shipping_origin_zip');
const originCountry = shippingSettings.find(s => s.key === 'shipping_origin_country');
const packageLength = shippingSettings.find(s => s.key === 'shipping_default_package_length');
const packageWidth = shippingSettings.find(s => s.key === 'shipping_default_package_width');
const packageHeight = shippingSettings.find(s => s.key === 'shipping_default_package_height');
const packageUnit = shippingSettings.find(s => s.key === 'shipping_default_package_unit');
const weightUnit = shippingSettings.find(s => s.key === 'shipping_default_weight_unit');
const carriersAllowed = shippingSettings.find(s => s.key === 'shipping_carriers_allowed');
if (shippingEnabled && shippingEnabled.value) config.shipping.enabled = shippingEnabled.value === 'true';
if (easypostEnabled && easypostEnabled.value) config.shipping.easypostEnabled = easypostEnabled.value === 'true';
if (easypostApiKey && easypostApiKey.value) config.shipping.easypostApiKey = easypostApiKey.value;
if (flatRate && flatRate.value) config.shipping.flatRate = parseFloat(flatRate.value);
if (freeThreshold && freeThreshold.value) config.shipping.freeThreshold = parseFloat(freeThreshold.value);
// Update origin address
if (originStreet && originStreet.value) config.shipping.originAddress.street = originStreet.value;
if (originCity && originCity.value) config.shipping.originAddress.city = originCity.value;
if (originState && originState.value) config.shipping.originAddress.state = originState.value;
if (originZip && originZip.value) config.shipping.originAddress.zip = originZip.value;
if (originCountry && originCountry.value) config.shipping.originAddress.country = originCountry.value;
// Update default package
if (packageLength && packageLength.value) config.shipping.defaultPackage.length = parseFloat(packageLength.value);
if (packageWidth && packageWidth.value) config.shipping.defaultPackage.width = parseFloat(packageWidth.value);
if (packageHeight && packageHeight.value) config.shipping.defaultPackage.height = parseFloat(packageHeight.value);
if (packageUnit && packageUnit.value) config.shipping.defaultPackage.unit = packageUnit.value;
if (weightUnit && weightUnit.value) config.shipping.defaultPackage.weightUnit = weightUnit.value;
// Update carriers allowed
if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(',');
}
// Update site settings if they exist in DB
const siteSettings = settings.filter(s => s.category === 'site');
if (siteSettings.length > 0) {
const siteDomain = siteSettings.find(s => s.key === 'site_domain');
const siteProtocol = siteSettings.find(s => s.key === 'site_protocol');
const siteApiDomain = siteSettings.find(s => s.key === 'site_api_domain');
const analyticApiKey = siteSettings.find(s => s.key === 'site_analytics_api_key');
const redisHost = siteSettings.find(s => s.key === 'site_redis_host');
const redisTLS = siteSettings.find(s => s.key === 'site_redis_tls');
const awsRegion = siteSettings.find(s => s.key === 'site_aws_region');
const awsS3Bucket = siteSettings.find(s => s.key === 'site_aws_s3_bucket');
const cdnDomain = siteSettings.find(s => s.key === 'site_cdn_domain');
const deployment = siteSettings.find(s => s.key === 'site_deployment');
const awsQueueUrl = siteSettings.find(s => s.key === 'site_aws_queue_url');
const readHost = siteSettings.find(s => s.key === 'site_read_host');
const maxConnections = siteSettings.find(s => s.key === 'site_db_max_connections');
const sessionSecret = siteSettings.find(s => s.key === 'site_session_secret');
const redisPort = siteSettings.find(s => s.key === 'site_redis_port');
const redisPassword = siteSettings.find(s => s.key === 'site_redis_password');
if (redisHost && redisHost.value) config.site.redisHost = redisHost.value;
if (redisTLS && redisTLS.value) config.site.redisTLS = redisHost.value;
if (awsRegion && awsRegion.value) config.site.awsRegion = awsRegion.value;
if (awsS3Bucket && awsS3Bucket.value) config.site.awsS3Bucket = awsS3Bucket.value;
if (cdnDomain && cdnDomain.value) config.site.cdnDomain = cdnDomain.value;
if (deployment && deployment.value) config.site.deployment = deployment.value;
if (awsQueueUrl && awsQueueUrl.value) config.site.awsQueueUrl = awsQueueUrl.value;
if (readHost && readHost.value) config.db.readHost = readHost.value;
if (maxConnections && maxConnections.value) config.db.maxConnections = maxConnections.value;
if (sessionSecret && sessionSecret.value) config.site.sessionSecret = sessionSecret.value;
if (redisPort && redisPort.value) config.site.redisPort = redisPort.value;
if (redisPassword && redisPassword.value) config.site.redisPassword = redisPassword.value;
if (siteDomain && siteDomain.value) config.site.domain = siteDomain.value;
if (siteProtocol && siteProtocol.value) config.site.protocol = siteProtocol.value;
if (siteApiDomain && siteApiDomain.value) config.site.apiDomain = siteApiDomain.value;
if (analyticApiKey && analyticApiKey.value) config.site.analyticApiKey = analyticApiKey.value;
}
};
module.exports = config;

View file

@ -1,73 +0,0 @@
const { Pool } = require('pg');
const config = require('../config')
let writePool, readPool;
// Always create write pool
writePool = new Pool({
user: config.db.user,
password: config.db.password,
host: config.db.host,
port: config.db.port,
database: config.db.database,
max: config.db.maxConnections,
});
// Create read pool only in cloud mode
if (config.site.deployment === 'cloud') {
readPool = new Pool({
user: config.db.user,
password: config.db.password,
host: config.db.readHost,
port: config.db.port,
database: config.db.database,
max: config.db.maxConnections,
});
} else {
// In self-hosted mode, use the same pool for reads and writes
readPool = writePool;
}
// Helper function that automatically routes queries
const query = async (text, params, options = {}) => {
const { forceWrite = false } = options;
const isWrite = forceWrite ||
text.trim().toUpperCase().startsWith('INSERT') ||
text.trim().toUpperCase().startsWith('UPDATE') ||
text.trim().toUpperCase().startsWith('DELETE') ||
text.trim().toUpperCase().includes('FOR UPDATE');
const pool = isWrite ? writePool : readPool;
const start = Date.now();
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (config.site.deployment === 'cloud') {
console.log('Executed query', {
text,
duration,
rows: res.rowCount,
pool: isWrite ? 'write' : 'read'
});
} else {
console.log('Executed query', { text, duration, rows: res.rowCount });
}
return res;
};
// Old Query function
// const query = async (text, params) => {
// const start = Date.now();
// const res = await pool.query(text, params);
// const duration = Date.now() - start;
// console.log('Executed query', { text, duration, rows: res.rowCount });
// return res;
// };
module.exports = { query, pool: writePool };

View file

@ -1,418 +0,0 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const cors = require('cors');
const morgan = require('morgan');
const config = require('./config');
const { query, pool } = require('./db')
const authMiddleware = require('./middleware/auth');
const adminAuthMiddleware = require('./middleware/adminAuth');
const settingsAdminRoutes = require('./routes/settingsAdmin');
const seoMiddleware = require('./middleware/seoMiddleware');
const SystemSettings = require('./models/SystemSettings');
const fs = require('fs');
// services
const notificationService = require('./services/notificationService');
const siteMapService = require('./services/sitemapService');
const emailService = require('./services/emailService');
const storageService = require('./services/storageService');
// routes
const stripePaymentRoutes = require('./routes/stripePayment');
const productRoutes = require('./routes/products');
const authRoutes = require('./routes/auth');
const cartRoutes = require('./routes/cart');
const productAdminRoutes = require('./routes/productAdmin');
const categoryAdminRoutes = require('./routes/categoryAdmin');
const usersAdminRoutes = require('./routes/userAdmin');
const ordersAdminRoutes = require('./routes/orderAdmin');
const couponsAdminRoutes = require('./routes/couponAdmin');
const userOrdersRoutes = require('./routes/userOrders');
const shippingRoutes = require('./routes/shipping');
const blogRoutes = require('./routes/blog');
const blogAdminRoutes = require('./routes/blogAdmin');
const blogCommentsAdminRoutes = require('./routes/blogCommentsAdmin');
const productReviewsRoutes = require('./routes/productReviews');
const productReviewsAdminRoutes = require('./routes/productReviewsAdmin');
const emailTemplatesAdminRoutes = require('./routes/emailTemplatesAdmin');
const publicSettingsRoutes = require('./routes/publicSettings');
const mailingListRoutes = require('./routes/mailingListAdmin');
const emailCampaignListRoutes = require('./routes/emailCampaignsAdmin');
const subscribersAdminRoutes = require('./routes/subscribersAdmin');
const subscribersRoutes = require('./routes/subscribers');
const emailTrackingRoutes = require('./routes/emailTracking');
// Create Express app
const app = express();
const port = config.port || 4000;
// Ensure uploads directories exist
const uploadsDir = path.join(__dirname, '../public/uploads');
const productImagesDir = path.join(uploadsDir, 'products');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
if (!fs.existsSync(productImagesDir)) {
fs.mkdirSync(productImagesDir, { recursive: true });
}
const blogImagesDir = path.join(uploadsDir, 'blog');
if (!fs.existsSync(blogImagesDir)) {
fs.mkdirSync(blogImagesDir, { recursive: true });
}
// Configure storage
// const storage = multer.diskStorage({
// destination: (req, file, cb) => {
// // Determine destination based on upload type
// if (req.originalUrl.includes('/product')) {
// cb(null, productImagesDir);
// } else {
// cb(null, uploadsDir);
// }
// },
// filename: (req, file, cb) => {
// // Create unique filename with original extension
// const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
// const fileExt = path.extname(file.originalname);
// const safeName = path.basename(file.originalname, fileExt)
// .toLowerCase()
// .replace(/[^a-z0-9]/g, '-');
// cb(null, `${safeName}-${uniqueSuffix}${fileExt}`);
// }
// });
// // File filter to only allow images
// const fileFilter = (req, file, cb) => {
// // Accept only image files
// if (file.mimetype.startsWith('image/')) {
// cb(null, true);
// } else {
// cb(new Error('Only image files are allowed!'), false);
// }
// };
// Create the multer instance
// const upload = multer({
// storage,
// fileFilter,
// limits: {
// fileSize: 5 * 1024 * 1024 // 5MB limit
// }
// });
const upload = storageService.getUploadMiddleware();
pool.connect()
.then(async () => {
console.log('Connected to PostgreSQL database');
// Load settings from database
try {
const settings = await SystemSettings.getAllSettings(pool, query);
if (settings) {
// Update config with database settings
config.updateFromDatabase(settings);
console.log('Loaded settings from database');
}
} catch (error) {
console.error('Error loading settings from database:', error);
}
})
.catch(err => console.error('Database connection error:', err))
.finally((() => {
setupRepeatedChecks(pool, query);
}));
async function setupRepeatedChecks(p, q){
let timeInterval = 60 * 1000;
let siteGeneratorInterval = 60 * 1000;
if (config.environment === 'prod') {
console.log('Setting up scheduled tasks for production environment, 10 mins, 60 mins');
timeInterval = 10 * 60 * 1000;
siteGeneratorInterval = 60 * 60 * 1000;
}else {
console.log('Setting up scheduled tasks for development environment, 2 mins, 10 mins');
timeInterval = 2 * 60 * 1000;
siteGeneratorInterval = 10 * 60 * 1000;
}
// Process stock notifications every X minutes
setInterval(async () => {
try {
console.log('Processing low stock notifications...');
const processedCount = await notificationService.processLowStockNotifications(p, q);
console.log(`Processed ${processedCount} low stock notifications`);
} catch (error) {
console.error('Error processing low stock notifications:', error);
}
}, timeInterval);
console.log('Processing sitemap...');
siteMapService.generateSitemap(p, q);
console.log(`Sitemap generated`);
setInterval(async () => {
try {
console.log('Processing sitemap...');
await siteMapService.generateSitemap(p, q);
console.log(`Sitemap generated`);
} catch (error) {
console.error('Error processing low stock notifications:', error);
}
}, siteGeneratorInterval);
}
// Handle SSL proxy headers
app.use((req, res, next) => {
// Trust X-Forwarded-Proto header from Cloudflare
if (req.headers['x-forwarded-proto'] === 'https') {
req.secure = true;
}
next();
});
app.set('trust proxy', true);
app.use(seoMiddleware);
// Middleware
app.use(cors({
origin: '*',
credentials: true
}));
app.use('/api/payment', stripePaymentRoutes(pool, query, authMiddleware(pool, query)));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(morgan('dev'));
// Serve static files - serve the entire public directory
app.use(express.static(path.join(__dirname, '../public')));
// More specific static file serving for images
app.use('/images', express.static(path.join(__dirname, '../public/uploads')));
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
app.use('/api/uploads', express.static(path.join(__dirname, '../public/uploads')));
app.use('/api/images', express.static(path.join(__dirname, '../public/uploads')));
app.use('/uploads/blog', express.static(path.join(__dirname, '../public/uploads/blog')));
app.use('/api/uploads/blog', express.static(path.join(__dirname, '../public/uploads/blog')));
if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) {
fs.mkdirSync(path.join(__dirname, '../public/uploads'), { recursive: true });
}
if (!fs.existsSync(path.join(__dirname, '../public/uploads/products'))) {
fs.mkdirSync(path.join(__dirname, '../public/uploads/products'), { recursive: true });
}
// For direct access to product images
app.use('/products/images', express.static(path.join(__dirname, '../public/uploads/products')));
app.use('/api/products/images', express.static(path.join(__dirname, '../public/uploads/products')));
// Health check route
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', message: 'API is running' });
});
app.use('/api/settings', publicSettingsRoutes(pool, query));
// Upload endpoints
// Public upload endpoint (basic)
app.post('/api/image/upload', upload.single('image'), (req, res) => {
console.log('/api/image/upload');
if (!req.file) {
return res.status(400).json({
error: true,
message: 'No image file provided'
});
}
const imagePath = req.file.path ? `/uploads/${req.file.filename}` : req.file.location;
res.json({
success: true,
imagePath: storageService.getImageUrl(imagePath)
});
// res.json({
// success: true,
// imagePath: `/uploads/${req.file.filename}`
// });
});
app.get('/api/public-file/:filename', (req, res) => {
const { filename } = req.params;
// Prevent path traversal attacks
if (filename.includes('..') || filename.includes('/')) {
return res.status(400).json({
error: true,
message: 'Invalid filename'
});
}
// Serve files from public uploads folder
res.sendFile(path.join(__dirname, '../public/uploads', filename));
});
app.use('/api/product-reviews', productReviewsRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/admin/product-reviews', productReviewsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/users', usersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/coupons', couponsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/subscribers', subscribersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/orders', ordersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/blog', blogAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/blog-comments', blogCommentsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/email-templates', emailTemplatesAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/mailing-lists', mailingListRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/email-campaigns', emailCampaignListRoutes(pool, query, adminAuthMiddleware(pool, query)));
// Admin-only product image upload
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
console.log('/api/image/product', req.file);
if (!req.file) {
return res.status(400).json({
error: true,
message: 'No image file provided'
});
}
// Get the relative path to the image
const imagePath = req.file.path ?
`/uploads/products/${req.file.filename}` :
req.file.location;
res.json({
success: true,
imagePath: storageService.getImageUrl(imagePath),
filename: req.file.filename || path.basename(req.file.location)
});
// const imagePath = `/uploads/products/${req.file.filename}`;
// res.json({
// success: true,
// imagePath,
// filename: req.file.filename
// });
});
// Upload multiple product images (admin only)
app.post('/api/image/products', adminAuthMiddleware(pool, query), upload.array('images', 10), (req, res) => {
console.log('/api/image/products', req.files);
if (!req.files || req.files.length === 0) {
return res.status(400).json({
error: true,
message: 'No image files provided'
});
}
// Get the relative paths to the images
const imagePaths = req.files.map(file => {
const imagePath = file.path ?
`/uploads/products/${file.filename}` :
file.location;
return {
imagePath: storageService.getImageUrl(imagePath),
filename: file.filename || path.basename(file.location)
};
});
res.json({
success: true,
images: imagePaths
});
// const imagePaths = req.files.map(file => ({
// imagePath: `/uploads/products/${file.filename}`,
// filename: file.filename
// }));
// res.json({
// success: true,
// images: imagePaths
// });
});
// Delete product image (admin only)
app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (req, res) => {
try {
const { filename } = req.params;
// Prevent path traversal attacks
if (filename.includes('..') || filename.includes('/')) {
return res.status(400).json({
error: true,
message: 'Invalid filename'
});
}
if (config.site.deployment === 'cloud' && config.site.awsS3Bucket) {
// Implementation for S3 deletion would go here
// For now, we'll just log and continue
console.log('S3 file deletion not implemented yet');
} else {
// Delete from local filesystem
const filePath = path.join(__dirname, '../public/uploads/products', filename);
// Check if file exists
if (!fs.existsSync(filePath)) {
return res.status(404).json({
error: true,
message: 'Image not found'
});
}
// Delete the file
fs.unlinkSync(filePath);
}
res.json({
success: true,
message: 'Image deleted successfully'
});
} catch (error) {
console.error('Error deleting image:', error);
res.status(500).json({
error: true,
message: 'Failed to delete image'
});
}
});
// Use routes
app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/products', productRoutes(pool, query));
app.use('/api/subscribers', subscribersRoutes(pool, query));
app.use('/api/email', emailTrackingRoutes(pool, query));
app.use('/api/auth', authRoutes(pool, query));
app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/blog', blogRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/shipping', shippingRoutes(pool, query, authMiddleware(pool, query)));
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: true,
message: err.message || 'An unexpected error occurred'
});
});
// Start server
app.listen(port, () => {
console.log(`Server running on port ${port} in ${config.environment} environment`);
console.log(`Deployment mode: ${config.site.deployment || 'self-hosted'}`);
});
module.exports = app;

View file

@ -1,65 +0,0 @@
/**
* Admin authentication middleware to protect admin routes
*/
module.exports = (pool, query) => {
return async (req, res, next) => {
// Get API key from header
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({
error: true,
message: 'API key is required'
});
}
try {
// Verify API key and check admin status
// const result = await query(
// 'SELECT id, email, first_name, last_name, is_admin FROM users WHERE api_key = $1',
// [apiKey]
// );
const result = await query(
`SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.is_admin,
CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin
FROM
users u
LEFT JOIN
superadmins s ON u.id = s.user_id
WHERE
u.api_key = $1`,
[apiKey]
);
if (result.rows.length === 0) {
return res.status(401).json({
error: true,
message: 'Invalid API key'
});
}
if (!result.rows[0].is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Add user to request object
req.user = result.rows[0];
next();
} catch (error) {
return res.status(500).json({
error: true,
message: 'Error authenticating user'
});
}
};
};

View file

@ -1,49 +0,0 @@
/**
* Authentication middleware to protect routes
*/
module.exports = (pool, query) => {
return async (req, res, next) => {
// Get API key from header
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({
error: true,
message: 'API key is required'
});
}
try {
// Verify API key
const result = await query(`
SELECT
u.*,
CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin
FROM
users u
LEFT JOIN
superadmins s ON u.id = s.user_id
WHERE
u.api_key = $1
`, [apiKey]);
if (result.rows.length === 0) {
return res.status(401).json({
error: true,
message: 'Invalid API key'
});
}
// Add user to request object
req.user = result.rows[0];
next();
} catch (error) {
return res.status(500).json({
error: true,
message: 'Error authenticating user'
});
}
};
};

View file

@ -1,19 +0,0 @@
/**
* Middleware to handle serving SEO files with correct content types
*/
const seoMiddleware = (req, res, next) => {
if (req.path === '/sitemap.xml') {
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD');
}
else if (req.path === '/robots.txt') {
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD');
}
next();
};
module.exports = seoMiddleware;

View file

@ -1,67 +0,0 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
// Create uploads directory if it doesn't exist
const uploadDir = path.join(__dirname, '../../public/uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Create seo-files directory if it doesn't exist
const seoDir = path.join(__dirname, '../../public/seo-files');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
if (!fs.existsSync(seoDir)) {
fs.mkdirSync(seoDir, { recursive: true });
}
// Create directories for different image types
const productImagesDir = path.join(uploadDir, 'products');
if (!fs.existsSync(productImagesDir)) {
fs.mkdirSync(productImagesDir, { recursive: true });
}
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Determine destination based on upload type
if (req.path.includes('/products')) {
cb(null, productImagesDir);
} else {
cb(null, uploadDir);
}
},
filename: (req, file, cb) => {
// Create unique filename with original extension
const uniqueId = uuidv4();
const fileExt = path.extname(file.originalname);
const safeName = path.basename(file.originalname, fileExt)
.toLowerCase()
.replace(/[^a-z0-9]/g, '-');
cb(null, `${safeName}-${uniqueId}${fileExt}`);
}
});
// File filter to only allow images
const fileFilter = (req, file, cb) => {
// Accept only image files
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed!'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB
}
});
module.exports = upload;

View file

@ -1,181 +0,0 @@
/**
* System settings model
* This module handles database operations for system settings
*/
/**
* Get all system settings
* @param {Object} db - Database connection pool
* @param {Function} query - Query function
* @returns {Promise<Array>} - Array of settings
*/
const getAllSettings = async (db, query) => {
try {
const result = await query(
'SELECT * FROM system_settings ORDER BY category, key'
);
return result.rows;
} catch (error) {
console.error('Error retrieving system settings:', error);
throw error;
}
};
/**
* Get settings by category
* @param {Object} db - Database connection pool
* @param {Function} query - Query function
* @param {string} category - Settings category
* @returns {Promise<Array>} - Array of settings in the category
*/
const getSettingsByCategory = async (db, query, category) => {
try {
const result = await query(
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
[category]
);
return result.rows;
} catch (error) {
console.error(`Error retrieving ${category} settings:`, error);
throw error;
}
};
/**
* Get a specific setting
* @param {Object} db - Database connection pool
* @param {Function} query - Query function
* @param {string} key - Setting key
* @returns {Promise<Object>} - Setting value
*/
const getSetting = async (db, query, key) => {
try {
const result = await query(
'SELECT * FROM system_settings WHERE key = $1',
[key]
);
return result.rows[0];
} catch (error) {
console.error(`Error retrieving setting ${key}:`, error);
throw error;
}
};
/**
* Update a setting
* @param {Object} db - Database connection pool
* @param {Function} query - Query function
* @param {string} key - Setting key
* @param {string} value - Setting value
* @param {string} category - Setting category
* @returns {Promise<Object>} - Updated setting
*/
const updateSetting = async (db, query, key, value, category) => {
try {
// Check if setting exists
const existing = await query(
'SELECT * FROM system_settings WHERE key = $1',
[key]
);
if (existing.rows.length === 0) {
// Create new setting
const result = await query(
'INSERT INTO system_settings (key, value, category) VALUES ($1, $2, $3) RETURNING *',
[key, value, category]
);
return result.rows[0];
} else {
// Update existing setting
const result = await query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2 RETURNING *',
[value, key]
);
return result.rows[0];
}
} catch (error) {
console.error(`Error updating setting ${key}:`, error);
throw error;
}
};
/**
* Update multiple settings at once
* @param {Object} db - Database connection pool
* @param {Function} query - Query function
* @param {Array} settings - Array of settings objects {key, value, category}
* @returns {Promise<Array>} - Array of updated settings
*/
const updateSettings = async (db, query, settings) => {
const client = await db.connect();
try {
await client.query('BEGIN');
const updatedSettings = [];
for (const setting of settings) {
const { key, value, category } = setting;
// Check if setting exists
const existing = await client.query(
'SELECT * FROM system_settings WHERE key = $1',
[key]
);
let result;
if (existing.rows.length === 0) {
// Create new setting
result = await client.query(
'INSERT INTO system_settings (key, value, category) VALUES ($1, $2, $3) RETURNING *',
[key, value, category]
);
} else {
// Update existing setting
result = await client.query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2 RETURNING *',
[value, key]
);
}
updatedSettings.push(result.rows[0]);
}
await client.query('COMMIT');
return updatedSettings;
} catch (error) {
await client.query('ROLLBACK');
console.error('Error updating settings:', error);
throw error;
} finally {
client.release();
}
};
/**
* Delete a setting
* @param {Object} db - Database connection pool
* @param {Function} query - Query function
* @param {string} key - Setting key
* @returns {Promise<boolean>} - Success status
*/
const deleteSetting = async (db, query, key) => {
try {
await query(
'DELETE FROM system_settings WHERE key = $1',
[key]
);
return true;
} catch (error) {
console.error(`Error deleting setting ${key}:`, error);
throw error;
}
};
module.exports = {
getAllSettings,
getSettingsByCategory,
getSetting,
updateSetting,
updateSettings,
deleteSetting
};

View file

@ -1,321 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const config = require('../config');
const emailService = require('../services/emailService');
const router = express.Router();
module.exports = (pool, query) => {
// Register new user
router.post('/register', async (req, res, next) => {
const { email, firstName, lastName, isSubscribed = false } = req.body;
try {
// Check if user already exists
const userCheck = await query(
'SELECT * FROM users WHERE email = $1',
[email]
);
const firstUserQuery = await query(
'SELECT count(*) FROM (SELECT 1 FROM users LIMIT 1) AS t',
[]
);
let isFirstUser = parseInt(firstUserQuery.rows[0]?.count) === 0;
if (userCheck.rows.length > 0) {
return res.status(400).json({
error: true,
message: 'User with this email already exists'
});
}
// Create new user
const result = await query(
'INSERT INTO users (email, first_name, last_name) VALUES ($1, $2, $3) RETURNING id, email',
[email, firstName, lastName]
);
console.log("REGISTERED NEW USER ", email)
// If First User, promot to Admin
if(isFirstUser){
console.log("First User, promoting ", email, " to admin")
await query(
'UPDATE users SET is_admin = TRUE WHERE email = $1',
[email]
);
await query(
'INSERT INTO superadmins (user_id) values ($1) RETURNING id, created_at',
[result.rows[0].id]
);
}
await emailService.sendWelcomeEmail({
to: email,
first_name: firstName
});
if(isSubscribed){
let subResult = await query(
`INSERT INTO subscribers (id, email, first_name, last_name, status)
VALUES ($1, $2, $3, $4, $5) RETURNING id, email`,
[result.rows[0].id, email, firstName, lastName, 'active']
);
let mailResult = await query(
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id)
VALUES ($1, $2)`,
['1db91b9b-b1f9-4892-80b5-51437d8b6045', result.rows[0].id]
);
}
res.status(201).json({
message: 'User registered successfully',
user: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Request login code
router.post('/login-request', async (req, res, next) => {
const { email } = req.body;
console.log('/login-request')
try {
// Check if user exists
const userResult = await query(
'SELECT * FROM users WHERE email = $1',
[email]
);
if (userResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'User not found'
});
}
const user = userResult.rows[0];
// Generate a random 6-digit code
// Set expiration time (15 minutes from now)
const authCode = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + 15);
// Create auth record
const authResult = await query(
'INSERT INTO authentications (code, expires_at) VALUES ($1, $2) RETURNING id',
[authCode, expiresAt]
);
const authId = authResult.rows[0].id;
// Update user with auth record
await query(
'UPDATE users SET current_auth_id = $1 WHERE id = $2',
[authId, user.id]
);
const loginLink = `${config.site.protocol}://${config.site.domain}/verify?code=${authCode}&email=${encodeURIComponent(email)}`;
try {
await emailService.sendLoginCodeEmail({
to: email,
code: authCode,
loginLink: loginLink
});
} catch (emailError) {
console.error('Failed to send login code email:', emailError);
}
let retObj = {
message: 'Login code sent to address: ' + email
}
if(process.env.ENVIRONMENT === "beta"){
retObj.code = authCode;
}
res.json(retObj);
} catch (error) {
next(error);
}
});
// Verify login code
router.post('/verify', async (req, res, next) => {
const { email, code } = req.body;
try {
// Get user and auth record
const userResult = await query(
`SELECT u.*, a.code, a.expires_at, a.is_used
FROM users u
JOIN authentications a ON u.current_auth_id = a.id
WHERE u.email = $1`,
[email]
);
if (userResult.rows.length === 0) {
return res.status(400).json({
error: true,
message: 'Invalid request or no login code requested'
});
}
const { code: storedCode, expires_at, is_used, id: userId, is_disabled } = userResult.rows[0];
// Check if account is disabled
if (is_disabled) {
return res.status(403).json({
error: true,
message: 'Your account has been disabled. Please contact support for assistance.'
});
}
// Check code
if (storedCode !== code) {
return res.status(400).json({
error: true,
message: 'Invalid code'
});
}
// Check if expired
if (new Date() > new Date(expires_at)) {
return res.status(400).json({
error: true,
message: 'Code has expired, Please restart login'
});
}
// Check if already used
if (is_used) {
return res.status(400).json({
error: true,
message: 'Code has already been used' ,
data: is_used
});
}
// Mark auth as used
await query(
'UPDATE authentications SET is_used = true WHERE code = $1',
[code]
);
// Generate API key
const apiKey = uuidv4();
// Save API key to user
await query(
'UPDATE users SET api_key = $1, last_login = NOW() WHERE id = $2',
[apiKey, userId]
);
// Get user information including admin status
// const userInfo = await query(
// 'SELECT id, email, first_name, last_name, is_admin FROM users WHERE id = $1',
// [userId]
// );
const userInfo = await query(
`SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.is_admin,
CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin
FROM
users u
LEFT JOIN
superadmins s ON u.id = s.user_id
WHERE
u.id = $1`,
[userId]
);
res.json({
message: 'Login successful',
userId: userId,
isAdmin: userInfo.rows[0].is_admin,
isSuperAdmin: userInfo.rows[0].is_super_admin,
firstName: userInfo.rows[0].first_name,
lastName: userInfo.rows[0].last_name,
email: userInfo.rows[0].email,
apiKey: apiKey
});
} catch (error) {
next(error);
}
});
//apikey check
router.post('/verify-key', async (req, res, next) => {
const { apiKey, email } = req.body;
try {
// Verify the API key against email
const result = await query(
'SELECT id, email, first_name, last_name, is_admin, is_disabled FROM users WHERE api_key = $1 AND email = $2',
[apiKey, email]
);
if (result.rows.length === 0) {
return res.status(401).json({
error: true,
message: 'Invalid API key'
});
}
if (result.rows[0].is_disabled) {
return res.status(403).json({
error: true,
message: 'Your account has been disabled. Please contact support for assistance.'
});
}
res.json({
valid: true,
user: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Logout
router.post('/logout', async (req, res, next) => {
const { userId } = req.body;
try {
// Get current auth ID
const userResult = await query(
'SELECT current_auth_id FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'User not found'
});
}
const authId = userResult.rows[0].current_auth_id;
// Clear auth ID and API key
await query(
'UPDATE users SET current_auth_id = NULL, api_key = NULL WHERE id = $1',
[userId]
);
// Delete auth record
if (authId) {
await query(
'DELETE FROM authentications WHERE id = $1',
[authId]
);
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,282 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Get all published blog posts (public)
router.get('/', async (req, res, next) => {
try {
const { category, tag, search, page = 1, limit = 10 } = req.query;
const offset = (page - 1) * limit;
let whereConditions = ["b.status = 'published'"];
const params = [];
let paramIndex = 1;
// Filter by category
if (category) {
params.push(category);
whereConditions.push(`bc.name = $${paramIndex}`);
paramIndex++;
}
// Filter by tag
if (tag) {
params.push(tag);
whereConditions.push(`t.name = $${paramIndex}`);
paramIndex++;
}
// Search functionality
if (search) {
params.push(`%${search}%`);
whereConditions.push(`(b.title ILIKE $${paramIndex} OR b.excerpt ILIKE $${paramIndex} OR b.content ILIKE $${paramIndex})`);
paramIndex++;
}
// Prepare WHERE clause
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(' AND ')}`
: '';
// Get total count for pagination
const countQuery = `
SELECT COUNT(DISTINCT b.id)
FROM blog_posts b
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
${whereClause}
`;
const countResult = await query(countQuery, params);
const total = parseInt(countResult.rows[0].count);
// Query posts with all related data
const postsQuery = `
SELECT
b.id, b.title, b.slug, b.excerpt, b.featured_image_path,
b.published_at, b.created_at, b.updated_at,
u.id as author_id, u.first_name as author_first_name, u.last_name as author_last_name,
bc.id as category_id, bc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
COUNT(DISTINCT c.id) as comment_count
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
LEFT JOIN blog_comments c ON b.id = c.post_id AND c.is_approved = true
${whereClause}
GROUP BY b.id, u.id, bc.id
ORDER BY b.published_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit), parseInt(offset));
const result = await query(postsQuery, params);
res.json({
posts: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
next(error);
}
});
// Get single blog post by slug (public)
router.get('/:slug', async (req, res, next) => {
try {
const { slug } = req.params;
// Get post with author, category, and tags
const postQuery = `
SELECT
b.id, b.title, b.slug, b.content, b.excerpt,
b.featured_image_path, b.status, b.published_at,
b.created_at, b.updated_at,
u.id as author_id, u.first_name as author_first_name, u.last_name as author_last_name,
bc.id as category_id, bc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
WHERE b.slug = $1 AND b.status = 'published'
GROUP BY b.id, u.id, bc.id
`;
const postResult = await query(postQuery, [slug]);
if (postResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const post = postResult.rows[0];
// Get post images
const imagesQuery = `
SELECT id, image_path, caption, display_order
FROM blog_post_images
WHERE post_id = $1
ORDER BY display_order
`;
const imagesResult = await query(imagesQuery, [post.id]);
// Get approved comments
const commentsQuery = `
SELECT
c.id, c.content, c.created_at,
c.parent_id,
u.id as user_id, u.first_name, u.last_name
FROM blog_comments c
JOIN users u ON c.user_id = u.id
WHERE c.post_id = $1 AND c.is_approved = true
ORDER BY c.created_at
`;
const commentsResult = await query(commentsQuery, [post.id]);
// Organize comments into threads
const commentThreads = [];
const commentMap = {};
commentsResult.rows.forEach(comment => {
commentMap[comment.id] = {
...comment,
replies: []
};
});
// Then, organize into threads
commentsResult.rows.forEach(comment => {
if (comment.parent_id) {
// This is a reply
if (commentMap[comment.parent_id]) {
commentMap[comment.parent_id].replies.push(commentMap[comment.id]);
}
} else {
// This is a top-level comment
commentThreads.push(commentMap[comment.id]);
}
});
res.json({
...post,
images: imagesResult.rows,
comments: commentThreads
});
} catch (error) {
next(error);
}
});
// Get blog categories (public)
router.get('/categories/all', async (req, res, next) => {
try {
const result = await query('SELECT * FROM blog_categories ORDER BY name');
res.json(result.rows);
} catch (error) {
next(error);
}
});
router.use(authMiddleware);
// Add comment to a blog post (authenticated users only)
router.post('/:postId/comments', async (req, res, next) => {
try {
const { postId } = req.params;
const { content, parentId, userId } = req.body;
// Check if user is authenticated
if (req.user.id !== userId) {
return res.status(401).json({
error: true,
message: 'You must be logged in to comment'
});
}
// Validate content
if (!content || content.trim() === '') {
return res.status(400).json({
error: true,
message: 'Comment content is required'
});
}
// Check if post exists and is published
const postCheck = await query(
"SELECT id FROM blog_posts WHERE id = $1 AND status = 'published'",
[postId]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found or not published'
});
}
// If this is a reply, check if parent comment exists
if (parentId) {
const parentCheck = await query(
'SELECT id FROM blog_comments WHERE id = $1 AND post_id = $2',
[parentId, postId]
);
if (parentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Parent comment not found'
});
}
}
const isApproved = req.user.is_admin ? true : false;
// Insert comment
const result = await query(
`INSERT INTO blog_comments
(id, post_id, user_id, parent_id, content, is_approved)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[uuidv4(), postId, userId, parentId || null, content, isApproved]
);
// Get user info for the response
const userResult = await query(
'SELECT first_name, last_name FROM users WHERE id = $1',
[userId]
);
const comment = {
...result.rows[0],
first_name: userResult.rows[0].first_name,
last_name: userResult.rows[0].last_name,
replies: []
};
res.status(201).json({
message: isApproved
? 'Comment added successfully'
: 'Comment submitted and awaiting approval',
comment
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,494 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const slugify = require('slugify');
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all blog posts (admin)
router.get('/', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all posts with basic info
const result = await query(`
SELECT
b.id, b.title, b.slug, b.excerpt, b.status,
b.featured_image_path, b.published_at, b.created_at,
u.first_name as author_first_name, u.last_name as author_last_name,
bc.name as category_name
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
ORDER BY
CASE WHEN b.status = 'draft' THEN 1
WHEN b.status = 'published' THEN 2
ELSE 3
END,
b.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get single blog post for editing (admin)
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get post with all details
const postQuery = `
SELECT
b.id, b.title, b.slug, b.content, b.excerpt,
b.featured_image_path, b.status, b.published_at,
b.created_at, b.updated_at, b.author_id, b.category_id,
u.first_name as author_first_name, u.last_name as author_last_name,
bc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
WHERE b.id = $1
GROUP BY b.id, u.id, bc.id
`;
const postResult = await query(postQuery, [id]);
if (postResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const post = postResult.rows[0];
// Get post images
const imagesQuery = `
SELECT id, image_path, caption, display_order
FROM blog_post_images
WHERE post_id = $1
ORDER BY display_order
`;
const imagesResult = await query(imagesQuery, [id]);
// Return the post with images
res.json({
...post,
images: imagesResult.rows
});
} catch (error) {
next(error);
}
});
// Create a new blog post
router.post('/', async (req, res, next) => {
try {
const {
title, content, excerpt, categoryId,
tags, featuredImagePath, status, publishNow
} = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!title || !content) {
return res.status(400).json({
error: true,
message: 'Title and content are required'
});
}
// Generate slug from title
let slug = slugify(title, {
lower: true, // convert to lower case
strict: true, // strip special characters
remove: /[*+~.()'"!:@]/g // regex to remove characters
});
const slugCheck = await query(
'SELECT id FROM blog_posts WHERE slug = $1',
[slug]
);
// If slug exists, append a random string
if (slugCheck.rows.length > 0) {
const randomString = Math.random().toString(36).substring(2, 8);
slug = `${slug}-${randomString}`;
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Set published_at date if publishing now
const publishedAt = (status === 'published' && publishNow) ? new Date() : null;
// Create the post
const postId = uuidv4();
const postResult = await client.query(
`INSERT INTO blog_posts
(id, title, slug, content, excerpt, author_id, category_id, status, featured_image_path, published_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[postId, title, slug, content, excerpt || null, req.user.id, categoryId || null,
status || 'draft', featuredImagePath || null, publishedAt]
);
// Add tags if provided
if (tags && Array.isArray(tags) && tags.length > 0) {
for (const tagName of tags) {
// Get or create tag
let tagId;
const tagResult = await client.query(
'SELECT id FROM tags WHERE name = $1',
[tagName]
);
if (tagResult.rows.length > 0) {
tagId = tagResult.rows[0].id;
} else {
const newTagResult = await client.query(
'INSERT INTO tags (id, name) VALUES ($1, $2) RETURNING id',
[uuidv4(), tagName]
);
tagId = newTagResult.rows[0].id;
}
// Add tag to post
await client.query(
'INSERT INTO blog_post_tags (post_id, tag_id) VALUES ($1, $2)',
[postId, tagId]
);
}
}
await client.query('COMMIT');
res.status(201).json({
message: 'Blog post created successfully',
post: postResult.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
// Update blog post
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const {
title, content, excerpt, categoryId,
tags, featuredImagePath, status, publishNow
} = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!title || !content) {
return res.status(400).json({
error: true,
message: 'Title and content are required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id, slug, status, published_at FROM blog_posts WHERE id = $1',
[id]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const existingPost = postCheck.rows[0];
let updatedSlug = existingPost.slug;
// Update slug if title changed
if (title) {
const newSlug = slugify(title, {
lower: true,
strict: true,
remove: /[*+~.()'"!:@]/g
});
// Only update slug if it's different
if (newSlug !== existingPost.slug) {
// Check if new slug already exists
const slugCheck = await query(
'SELECT id FROM blog_posts WHERE slug = $1 AND id != $2',
[newSlug, id]
);
if (slugCheck.rows.length === 0) {
updatedSlug = newSlug;
} else {
// Append random string to slug
const randomString = Math.random().toString(36).substring(2, 8);
updatedSlug = `${newSlug}-${randomString}`;
}
}
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Determine published_at date
let publishedAt = existingPost.published_at;
if (status === 'published' && publishNow && !existingPost.published_at) {
publishedAt = new Date();
}
// Update the post
const postResult = await client.query(
`UPDATE blog_posts SET
title = $1,
slug = $2,
content = $3,
excerpt = $4,
category_id = $5,
status = $6,
featured_image_path = $7,
published_at = $8,
updated_at = NOW()
WHERE id = $9
RETURNING *`,
[title, updatedSlug, content, excerpt || null, categoryId || null,
status || existingPost.status, featuredImagePath, publishedAt, id]
);
// Update tags if provided
if (tags !== undefined) {
// Remove existing tags
await client.query('DELETE FROM blog_post_tags WHERE post_id = $1', [id]);
// Add new tags
if (Array.isArray(tags) && tags.length > 0) {
for (const tagName of tags) {
// Get or create tag
let tagId;
const tagResult = await client.query(
'SELECT id FROM tags WHERE name = $1',
[tagName]
);
if (tagResult.rows.length > 0) {
tagId = tagResult.rows[0].id;
} else {
const newTagResult = await client.query(
'INSERT INTO tags (id, name) VALUES ($1, $2) RETURNING id',
[uuidv4(), tagName]
);
tagId = newTagResult.rows[0].id;
}
// Add tag to post
await client.query(
'INSERT INTO blog_post_tags (post_id, tag_id) VALUES ($1, $2)',
[id, tagId]
);
}
}
}
await client.query('COMMIT');
res.json({
message: 'Blog post updated successfully',
post: postResult.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
// Delete blog post
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id FROM blog_posts WHERE id = $1',
[id]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
// Delete post - cascade will handle related records
await query(
'DELETE FROM blog_posts WHERE id = $1',
[id]
);
res.json({
message: 'Blog post deleted successfully'
});
} catch (error) {
next(error);
}
});
// Upload blog post image
router.post('/:postId/images', async (req, res, next) => {
try {
const { postId } = req.params;
const { imagePath, caption, displayOrder } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id FROM blog_posts WHERE id = $1',
[postId]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
// Check if image path is provided
if (!imagePath) {
return res.status(400).json({
error: true,
message: 'Image path is required'
});
}
// Add image to post
const result = await query(
`INSERT INTO blog_post_images (id, post_id, image_path, caption, display_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[uuidv4(), postId, imagePath, caption || null, displayOrder || 0]
);
res.status(201).json({
message: 'Image added to blog post',
image: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Delete blog post image
router.delete('/:postId/images/:imageId', async (req, res, next) => {
try {
const { postId, imageId } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if image exists and belongs to post
const imageCheck = await query(
'SELECT id FROM blog_post_images WHERE id = $1 AND post_id = $2',
[imageId, postId]
);
if (imageCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Image not found or does not belong to this post'
});
}
// Delete image
await query(
'DELETE FROM blog_post_images WHERE id = $1',
[imageId]
);
res.json({
message: 'Image deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,208 +0,0 @@
const express = require('express');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all pending comments (admin)
router.get('/pending', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all pending comments with related data
const result = await query(`
SELECT
c.id, c.content, c.created_at,
c.parent_id, c.post_id, c.user_id,
u.first_name, u.last_name, u.email,
b.title as post_title, b.slug as post_slug
FROM blog_comments c
JOIN users u ON c.user_id = u.id
JOIN blog_posts b ON c.post_id = b.id
WHERE c.is_approved = false
ORDER BY c.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get all comments for a post (admin)
router.get('/posts/:postId', async (req, res, next) => {
try {
const { postId } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id, title FROM blog_posts WHERE id = $1',
[postId]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const post = postCheck.rows[0];
// Get all comments for the post
const commentsQuery = `
SELECT
c.id, c.content, c.created_at,
c.parent_id, c.is_approved,
u.id as user_id, u.first_name, u.last_name, u.email
FROM blog_comments c
JOIN users u ON c.user_id = u.id
WHERE c.post_id = $1
ORDER BY c.created_at DESC
`;
const commentsResult = await query(commentsQuery, [postId]);
// Organize comments into threads
const commentThreads = [];
const commentMap = {};
// First, create a map of all comments
commentsResult.rows.forEach(comment => {
commentMap[comment.id] = {
...comment,
replies: []
};
});
// Then, organize into threads
commentsResult.rows.forEach(comment => {
if (comment.parent_id) {
// This is a reply
if (commentMap[comment.parent_id]) {
commentMap[comment.parent_id].replies.push(commentMap[comment.id]);
}
} else {
// This is a top-level comment
commentThreads.push(commentMap[comment.id]);
}
});
res.json({
post: {
id: post.id,
title: post.title
},
comments: commentThreads
});
} catch (error) {
next(error);
}
});
// Approve a comment
router.post('/:commentId/approve', async (req, res, next) => {
try {
const { commentId } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if comment exists
const commentCheck = await query(
'SELECT id, is_approved FROM blog_comments WHERE id = $1',
[commentId]
);
if (commentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Comment not found'
});
}
// Check if comment is already approved
if (commentCheck.rows[0].is_approved) {
return res.status(400).json({
error: true,
message: 'Comment is already approved'
});
}
// Approve comment
const result = await query(
'UPDATE blog_comments SET is_approved = true WHERE id = $1 RETURNING *',
[commentId]
);
res.json({
message: 'Comment approved successfully',
comment: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Delete a comment
router.delete('/:commentId', async (req, res, next) => {
try {
const { commentId } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if comment exists
const commentCheck = await query(
'SELECT id FROM blog_comments WHERE id = $1',
[commentId]
);
if (commentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Comment not found'
});
}
// Delete comment - cascade will handle child comments
await query(
'DELETE FROM blog_comments WHERE id = $1',
[commentId]
);
res.json({
message: 'Comment deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};

File diff suppressed because it is too large Load diff

View file

@ -1,186 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all categories
router.get('/', async (req, res, next) => {
try {
const result = await query('SELECT * FROM product_categories ORDER BY name ASC');
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get single category by ID
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const result = await query(
'SELECT * FROM product_categories WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Category not found'
});
}
res.json(result.rows[0]);
} catch (error) {
next(error);
}
});
// Create a new category
router.post('/', async (req, res, next) => {
try {
const { name, description, imagePath } = req.body;
// Validate required fields
if (!name) {
return res.status(400).json({
error: true,
message: 'Category name is required'
});
}
// Check if category with same name already exists
const existingCategory = await query(
'SELECT * FROM product_categories WHERE name = $1',
[name]
);
if (existingCategory.rows.length > 0) {
return res.status(400).json({
error: true,
message: 'A category with this name already exists'
});
}
// Create new category
const result = await query(
'INSERT INTO product_categories (id, name, description, image_path) VALUES ($1, $2, $3, $4) RETURNING *',
[uuidv4(), name, description || null, imagePath || null]
);
res.status(201).json({
message: 'Category created successfully',
category: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Update a category
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { name, description, imagePath } = req.body;
// Validate required fields
if (!name) {
return res.status(400).json({
error: true,
message: 'Category name is required'
});
}
// Check if category exists
const categoryCheck = await query(
'SELECT * FROM product_categories WHERE id = $1',
[id]
);
if (categoryCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Category not found'
});
}
// Check if new name conflicts with existing category
if (name !== categoryCheck.rows[0].name) {
const nameCheck = await query(
'SELECT * FROM product_categories WHERE name = $1 AND id != $2',
[name, id]
);
if (nameCheck.rows.length > 0) {
return res.status(400).json({
error: true,
message: 'A category with this name already exists'
});
}
}
// Update category
const result = await query(
'UPDATE product_categories SET name = $1, description = $2, image_path = $3 WHERE id = $4 RETURNING *',
[name, description || null, imagePath, id]
);
res.json({
message: 'Category updated successfully',
category: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Delete a category
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// Check if category exists
const categoryCheck = await query(
'SELECT * FROM product_categories WHERE id = $1',
[id]
);
if (categoryCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Category not found'
});
}
// Check if category is being used by products
const productsUsingCategory = await query(
'SELECT COUNT(*) FROM products WHERE category_id = $1',
[id]
);
if (parseInt(productsUsingCategory.rows[0].count) > 0) {
return res.status(400).json({
error: true,
message: 'This category cannot be deleted because it is associated with products. Please reassign those products to a different category first.'
});
}
// Delete category
await query(
'DELETE FROM product_categories WHERE id = $1',
[id]
);
res.json({
message: 'Category deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,689 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
/**
* GET /api/admin/coupons
* Get all coupons
*/
router.get('/', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all coupons with category and tag information
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT COUNT(*)
FROM coupon_redemptions cr
WHERE cr.coupon_id = c.id
) AS redemption_count
FROM coupons c
ORDER BY c.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
/**
* GET /api/admin/coupons/:id
* Get single coupon by ID
*/
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get coupon with categories, tags, and blacklisted products
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT json_agg(
json_build_object(
'id', p.id,
'name', p.name
)
)
FROM coupon_blacklist bl
JOIN products p ON bl.product_id = p.id
WHERE bl.coupon_id = c.id
) AS blacklisted_products
FROM coupons c
WHERE c.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
res.json(result.rows[0]);
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/coupons
* Create a new coupon
*/
router.post('/', async (req, res, next) => {
try {
const {
code,
description,
discountType,
discountValue,
minPurchaseAmount,
maxDiscountAmount,
redemptionLimit,
startDate,
endDate,
isActive,
categories,
tags,
blacklistedProducts
} = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!code || !discountType || !discountValue) {
return res.status(400).json({
error: true,
message: 'Code, discount type, and discount value are required'
});
}
// Validate discount type
if (!['percentage', 'fixed_amount'].includes(discountType)) {
return res.status(400).json({
error: true,
message: 'Discount type must be either "percentage" or "fixed_amount"'
});
}
// Validate discount value
if (discountType === 'percentage' && (discountValue <= 0 || discountValue > 100)) {
return res.status(400).json({
error: true,
message: 'Percentage discount must be between 0 and 100'
});
}
if (discountType === 'fixed_amount' && discountValue <= 0) {
return res.status(400).json({
error: true,
message: 'Fixed amount discount must be greater than 0'
});
}
// Check if code is already used
const existingCoupon = await query(
'SELECT * FROM coupons WHERE code = $1',
[code]
);
if (existingCoupon.rows.length > 0) {
return res.status(400).json({
error: true,
message: 'Coupon code already exists'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Create coupon
const couponResult = await client.query(`
INSERT INTO coupons (
id, code, description, discount_type, discount_value,
min_purchase_amount, max_discount_amount, redemption_limit,
start_date, end_date, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`, [
uuidv4(),
code,
description || null,
discountType,
discountValue,
minPurchaseAmount || null,
maxDiscountAmount || null,
redemptionLimit || null,
startDate || null,
endDate || null,
isActive !== undefined ? isActive : true
]);
const couponId = couponResult.rows[0].id;
// Add categories if provided
if (categories && categories.length > 0) {
for (const categoryId of categories) {
await client.query(
'INSERT INTO coupon_categories (coupon_id, category_id) VALUES ($1, $2)',
[couponId, categoryId]
);
}
}
// Add tags if provided
if (tags && tags.length > 0) {
for (const tagId of tags) {
await client.query(
'INSERT INTO coupon_tags (coupon_id, tag_id) VALUES ($1, $2)',
[couponId, tagId]
);
}
}
// Add blacklisted products if provided
if (blacklistedProducts && blacklistedProducts.length > 0) {
for (const productId of blacklistedProducts) {
await client.query(
'INSERT INTO coupon_blacklist (coupon_id, product_id) VALUES ($1, $2)',
[couponId, productId]
);
}
}
await client.query('COMMIT');
// Get the complete coupon data
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT json_agg(
json_build_object(
'id', p.id,
'name', p.name
)
)
FROM coupon_blacklist bl
JOIN products p ON bl.product_id = p.id
WHERE bl.coupon_id = c.id
) AS blacklisted_products
FROM coupons c
WHERE c.id = $1
`, [couponId]);
res.status(201).json({
message: 'Coupon created successfully',
coupon: result.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* PUT /api/admin/coupons/:id
* Update a coupon
*/
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const {
code,
description,
discountType,
discountValue,
minPurchaseAmount,
maxDiscountAmount,
redemptionLimit,
startDate,
endDate,
isActive,
categories,
tags,
blacklistedProducts
} = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if coupon exists
const couponCheck = await query(
'SELECT * FROM coupons WHERE id = $1',
[id]
);
if (couponCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
// Validate discount type if provided
if (discountType && !['percentage', 'fixed_amount'].includes(discountType)) {
return res.status(400).json({
error: true,
message: 'Discount type must be either "percentage" or "fixed_amount"'
});
}
// Validate discount value if provided
if (discountType === 'percentage' && discountValue !== undefined && (discountValue <= 0 || discountValue > 100)) {
return res.status(400).json({
error: true,
message: 'Percentage discount must be between 0 and 100'
});
}
if (discountType === 'fixed_amount' && discountValue !== undefined && discountValue <= 0) {
return res.status(400).json({
error: true,
message: 'Fixed amount discount must be greater than 0'
});
}
// Check if new code conflicts with existing coupons
if (code && code !== couponCheck.rows[0].code) {
const codeCheck = await query(
'SELECT * FROM coupons WHERE code = $1 AND id != $2',
[code, id]
);
if (codeCheck.rows.length > 0) {
return res.status(400).json({
error: true,
message: 'Coupon code already exists'
});
}
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Update coupon
const updateFields = [];
const updateValues = [];
let paramIndex = 1;
if (code !== undefined) {
updateFields.push(`code = $${paramIndex}`);
updateValues.push(code);
paramIndex++;
}
if (description !== undefined) {
updateFields.push(`description = $${paramIndex}`);
updateValues.push(description);
paramIndex++;
}
if (discountType !== undefined) {
updateFields.push(`discount_type = $${paramIndex}`);
updateValues.push(discountType);
paramIndex++;
}
if (discountValue !== undefined) {
updateFields.push(`discount_value = $${paramIndex}`);
updateValues.push(discountValue);
paramIndex++;
}
if (minPurchaseAmount !== undefined) {
updateFields.push(`min_purchase_amount = $${paramIndex}`);
updateValues.push(minPurchaseAmount);
paramIndex++;
}
if (maxDiscountAmount !== undefined) {
updateFields.push(`max_discount_amount = $${paramIndex}`);
updateValues.push(maxDiscountAmount);
paramIndex++;
}
if (redemptionLimit !== undefined) {
updateFields.push(`redemption_limit = $${paramIndex}`);
updateValues.push(redemptionLimit);
paramIndex++;
}
if (startDate !== undefined) {
updateFields.push(`start_date = $${paramIndex}`);
updateValues.push(startDate);
paramIndex++;
}
if (endDate !== undefined) {
updateFields.push(`end_date = $${paramIndex}`);
updateValues.push(endDate);
paramIndex++;
}
if (isActive !== undefined) {
updateFields.push(`is_active = $${paramIndex}`);
updateValues.push(isActive);
paramIndex++;
}
// Only update if there are fields to update
if (updateFields.length > 0) {
updateValues.push(id);
await client.query(`
UPDATE coupons
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
`, updateValues);
}
// Update categories if provided
if (categories !== undefined) {
// Remove existing categories
await client.query(
'DELETE FROM coupon_categories WHERE coupon_id = $1',
[id]
);
// Add new categories
if (categories && categories.length > 0) {
for (const categoryId of categories) {
await client.query(
'INSERT INTO coupon_categories (coupon_id, category_id) VALUES ($1, $2)',
[id, categoryId]
);
}
}
}
// Update tags if provided
if (tags !== undefined) {
// Remove existing tags
await client.query(
'DELETE FROM coupon_tags WHERE coupon_id = $1',
[id]
);
// Add new tags
if (tags && tags.length > 0) {
for (const tagId of tags) {
await client.query(
'INSERT INTO coupon_tags (coupon_id, tag_id) VALUES ($1, $2)',
[id, tagId]
);
}
}
}
// Update blacklisted products if provided
if (blacklistedProducts !== undefined) {
// Remove existing blacklisted products
await client.query(
'DELETE FROM coupon_blacklist WHERE coupon_id = $1',
[id]
);
// Add new blacklisted products
if (blacklistedProducts && blacklistedProducts.length > 0) {
for (const productId of blacklistedProducts) {
await client.query(
'INSERT INTO coupon_blacklist (coupon_id, product_id) VALUES ($1, $2)',
[id, productId]
);
}
}
}
await client.query('COMMIT');
// Get the updated coupon data
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT json_agg(
json_build_object(
'id', p.id,
'name', p.name
)
)
FROM coupon_blacklist bl
JOIN products p ON bl.product_id = p.id
WHERE bl.coupon_id = c.id
) AS blacklisted_products
FROM coupons c
WHERE c.id = $1
`, [id]);
res.json({
message: 'Coupon updated successfully',
coupon: result.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* DELETE /api/admin/coupons/:id
* Delete a coupon
*/
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if coupon exists
const couponCheck = await query(
'SELECT * FROM coupons WHERE id = $1',
[id]
);
if (couponCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
// Delete coupon (cascade will handle related records)
await query(
'DELETE FROM coupons WHERE id = $1',
[id]
);
res.json({
message: 'Coupon deleted successfully'
});
} catch (error) {
next(error);
}
});
/**
* GET /api/admin/coupons/:id/redemptions
* Get coupon redemption history
*/
router.get('/:id/redemptions', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if coupon exists
const couponCheck = await query(
'SELECT * FROM coupons WHERE id = $1',
[id]
);
if (couponCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
// Get redemption history
const result = await query(`
SELECT cr.*, u.email, u.first_name, u.last_name, o.total_amount
FROM coupon_redemptions cr
JOIN users u ON cr.user_id = u.id
JOIN orders o ON cr.order_id = o.id
WHERE cr.coupon_id = $1
ORDER BY cr.redeemed_at DESC
`, [id]);
res.json(result.rows);
} catch (error) {
next(error);
}
});
return router;
};

File diff suppressed because it is too large Load diff

View file

@ -1,602 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const nodemailer = require('nodemailer');
const config = require('../config');
// Create email transporter
const createTransporter = () => {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
};
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
/**
* Get all email templates
* GET /api/admin/email-templates
*/
router.get('/', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
['email_templates']
);
// Transform settings into template objects
const templates = result.rows.map(setting => {
try {
// Parse the template data from the JSON value
const templateData = JSON.parse(setting.value);
return {
id: setting.key,
...templateData
};
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
return null;
}
}).filter(Boolean); // Remove any null entries
res.json(templates);
} catch (error) {
next(error);
}
});
/**
* Get templates by type
* GET /api/admin/email-templates/type/:type
*/
router.get('/type/:type', async (req, res, next) => {
try {
const { type } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
['email_templates']
);
// Transform settings into template objects and filter by type
const templates = result.rows
.map(setting => {
try {
// Parse the template data from the JSON value
const templateData = JSON.parse(setting.value);
if (templateData.type === type) {
return {
id: setting.key,
...templateData
};
}
return null;
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
return null;
}
})
.filter(Boolean); // Remove any null entries
res.json(templates);
} catch (error) {
next(error);
}
});
/**
* Get default template for a type
* GET /api/admin/email-templates/default/:type
*/
router.get('/default/:type', async (req, res, next) => {
try {
const { type } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
['email_templates']
);
// Find the default template for the specified type
let defaultTemplate = null;
for (const setting of result.rows) {
try {
const templateData = JSON.parse(setting.value);
if (templateData.type === type && templateData.isDefault) {
defaultTemplate = {
id: setting.key,
...templateData
};
break;
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
if (defaultTemplate) {
res.json(defaultTemplate);
} else {
res.status(404).json({
error: true,
message: `No default template found for type: ${type}`
});
}
} catch (error) {
next(error);
}
});
/**
* Get a single template by ID
* GET /api/admin/email-templates/:id
*/
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get the setting by key
const result = await query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
try {
// Parse the template data from the JSON value
const templateData = JSON.parse(result.rows[0].value);
res.json({
id: result.rows[0].key,
...templateData
});
} catch (e) {
console.error(`Failed to parse template setting: ${id}`, e);
return res.status(500).json({
error: true,
message: 'Failed to parse template data'
});
}
} catch (error) {
next(error);
}
});
/**
* Create a new template
* POST /api/admin/email-templates
*/
router.post('/', async (req, res, next) => {
try {
const { name, type, subject, content, isDefault } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!name || !type || !subject || !content) {
return res.status(400).json({
error: true,
message: 'Name, type, subject, and content are required'
});
}
// Begin transaction for potential default template updates
const client = await pool.connect();
try {
await client.query('BEGIN');
// Generate a unique key for the setting
const templateKey = `email_template_${Date.now()}`;
// Create the template object
const templateData = {
name,
type,
subject,
content,
isDefault: isDefault || false,
createdAt: new Date().toISOString()
};
// If this template should be the default, unset any existing defaults
if (templateData.isDefault) {
// Get all settings with 'email_templates' category
const existingTemplates = await client.query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find and update any existing default templates of the same type
for (const setting of existingTemplates.rows) {
try {
const existingData = JSON.parse(setting.value);
if (existingData.type === type && existingData.isDefault) {
existingData.isDefault = false;
existingData.updatedAt = new Date().toISOString();
await client.query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
[JSON.stringify(existingData), setting.key]
);
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
}
// Insert the new template
await client.query(
'INSERT INTO system_settings (key, value, category) VALUES ($1, $2, $3)',
[templateKey, JSON.stringify(templateData), 'email_templates']
);
await client.query('COMMIT');
res.status(201).json({
id: templateKey,
...templateData
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Update a template
* PUT /api/admin/email-templates/:id
*/
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { name, type, subject, content, isDefault } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!name || !type || !subject || !content) {
return res.status(400).json({
error: true,
message: 'Name, type, subject, and content are required'
});
}
// Begin transaction for potential default template updates
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if the template exists
const templateCheck = await client.query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (templateCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
// Parse the existing template data
let existingData;
try {
existingData = JSON.parse(templateCheck.rows[0].value);
} catch (e) {
await client.query('ROLLBACK');
return res.status(500).json({
error: true,
message: 'Failed to parse existing template data'
});
}
// Create the updated template object
const templateData = {
name,
type,
subject,
content,
isDefault: isDefault !== undefined ? isDefault : existingData.isDefault,
createdAt: existingData.createdAt,
updatedAt: new Date().toISOString()
};
// If this template should be the default, unset any existing defaults
if (templateData.isDefault && (!existingData.isDefault || existingData.type !== type)) {
// Get all settings with 'email_templates' category
const existingTemplates = await client.query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find and update any existing default templates of the same type
for (const setting of existingTemplates.rows) {
if (setting.key === id) continue; // Skip the current template
try {
const otherData = JSON.parse(setting.value);
if (otherData.type === type && otherData.isDefault) {
otherData.isDefault = false;
otherData.updatedAt = new Date().toISOString();
await client.query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
[JSON.stringify(otherData), setting.key]
);
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
}
// Update the template
await client.query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
[JSON.stringify(templateData), id]
);
await client.query('COMMIT');
res.json({
id,
...templateData
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Delete a template
* DELETE /api/admin/email-templates/:id
*/
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if the template exists and is not a default template
const templateCheck = await query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (templateCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
// Parse the template data to check if it's a default template
try {
const templateData = JSON.parse(templateCheck.rows[0].value);
if (templateData.isDefault) {
return res.status(400).json({
error: true,
message: 'Cannot delete a default template. Please set another template as default first.'
});
}
} catch (e) {
console.error(`Failed to parse template setting: ${id}`, e);
}
// Delete the template
await query(
'DELETE FROM system_settings WHERE key = $1',
[id]
);
res.json({
success: true,
message: 'Template deleted successfully'
});
} catch (error) {
next(error);
}
});
/**
* Send a test email using a template
* POST /api/admin/email-templates/:id/test
*/
router.post('/:id/test', async (req, res, next) => {
try {
const { id } = req.params;
const { email, variables } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate email address
if (!email) {
return res.status(400).json({
error: true,
message: 'Email address is required'
});
}
// Check if the template exists
const templateCheck = await query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (templateCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
// Parse the template data
let templateData;
try {
templateData = JSON.parse(templateCheck.rows[0].value);
} catch (e) {
console.error(`Failed to parse template setting: ${id}`, e);
return res.status(500).json({
error: true,
message: 'Failed to parse template data'
});
}
// Replace variables in template
let emailContent = templateData.content;
let emailSubject = templateData.subject;
if (variables) {
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
emailContent = emailContent.replace(new RegExp(placeholder, 'g'), value);
emailSubject = emailSubject.replace(new RegExp(placeholder, 'g'), value);
}
}
// Create a transporter
const transporter = createTransporter();
// Send the test email
await transporter.sendMail({
from: config.email.reply,
to: email,
subject: `[TEST] ${emailSubject}`,
html: emailContent
});
res.json({
success: true,
message: `Test email sent to ${email}`
});
} catch (error) {
next(error);
}
});
/**
* Utility function to get a template by type
* @param {string} type - Template type
* @returns {Promise<Object|null>} Template object or null if not found
*/
async function getTemplateByType(type) {
try {
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find the default template for the specified type
let defaultTemplate = null;
for (const setting of result.rows) {
try {
const templateData = JSON.parse(setting.value);
if (templateData.type === type && templateData.isDefault) {
defaultTemplate = {
id: setting.key,
...templateData
};
break;
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
return defaultTemplate;
} catch (error) {
console.error('Error getting template by type:', error);
return null;
}
}
return router;
};

View file

@ -1,136 +0,0 @@
const express = require('express');
const router = express.Router();
module.exports = (pool, query) => {
/**
* Handle email tracking for opens and clicks
* GET /api/email/track
*
* Query parameters:
* - c: Campaign ID
* - s: Subscriber ID
* - t: Type of tracking (open, click)
* - l: Link ID (only for click tracking)
* - u: Original URL (only for click tracking, encoded)
*/
router.get('/track', async (req, res, next) => {
try {
const { c: campaignId, s: subscriberId, t: type, l: linkId, u: encodedUrl } = req.query;
// Validate required parameters
if (!campaignId || !subscriberId || !type) {
// For tracking pixels, return a 1x1 transparent GIF to avoid breaking email rendering
if (type === 'open') {
return sendTrackingPixel(res);
}
// For click tracking, redirect to homepage if parameters are invalid
if (type === 'click' && encodedUrl) {
return res.redirect(decodeURIComponent(encodedUrl));
}
return res.redirect('/');
}
// Process tracking event asynchronously (don't wait for DB operations)
processTrackingEvent(campaignId, subscriberId, type, linkId, encodedUrl)
.catch(err => console.error('Error processing tracking event:', err));
// Respond based on tracking type
if (type === 'open') {
// For opens, return a 1x1 transparent GIF
return sendTrackingPixel(res);
} else if (type === 'click' && encodedUrl) {
// For clicks, redirect to the original URL
return res.redirect(decodeURIComponent(encodedUrl));
} else {
// Fallback to homepage
return res.redirect('/');
}
} catch (error) {
console.error('Tracking error:', error);
// Always provide a response, even on error
if (req.query.t === 'open') {
return sendTrackingPixel(res);
} else {
return res.redirect('/');
}
}
});
/**
* Process a tracking event and update subscriber activity
* @param {string} campaignId - Campaign ID
* @param {string} subscriberId - Subscriber ID
* @param {string} type - Event type (open, click)
* @param {string} linkId - Link ID for click events
* @param {string} encodedUrl - Original URL for click events
* @returns {Promise<void>}
*/
async function processTrackingEvent(campaignId, subscriberId, type, linkId, encodedUrl) {
try {
// Get client IP and user agent
const details = {};
// Record the tracking event in subscriber_activity
if (type === 'open') {
// Check if an open has already been recorded for this subscriber/campaign
const existingOpen = await query(
`SELECT id FROM subscriber_activity
WHERE subscriber_id = $1 AND campaign_id = $2 AND type = 'open'
LIMIT 1`,
[subscriberId, campaignId]
);
// Only record first open to avoid duplicate counting
if (existingOpen.rows.length === 0) {
await query(
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, details)
VALUES ($1, $2, 'open', $3)`,
[subscriberId, campaignId, JSON.stringify(details)]
);
// Update subscriber's last_activity_at
await query(
`UPDATE subscribers SET last_activity_at = NOW() WHERE id = $1`,
[subscriberId]
);
}
} else if (type === 'click' && linkId) {
// Record the click with the link ID
await query(
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, link_id, url, details)
VALUES ($1, $2, 'click', $3, $4, $5)`,
[subscriberId, campaignId, linkId, decodeURIComponent(encodedUrl), JSON.stringify(details)]
);
// Update subscriber's last_activity_at
await query(
`UPDATE subscribers SET last_activity_at = NOW() WHERE id = $1`,
[subscriberId]
);
}
} catch (error) {
console.error('Error processing tracking event:', error);
// Don't throw - we want to fail silently for tracking
}
}
/**
* Send a 1x1 transparent GIF for tracking pixels
* @param {Object} res - Express response object
*/
function sendTrackingPixel(res) {
// 1x1 transparent GIF in base64
const transparentGif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
res.set('Content-Type', 'image/gif');
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
res.send(transparentGif);
}
return router;
};

View file

@ -1,180 +0,0 @@
const express = require('express');
const router = express.Router();
const upload = require('../middleware/upload');
const path = require('path');
const fs = require('fs');
const authMiddleware = require('../middleware/auth');
const adminAuthMiddleware = require('../middleware/adminAuth');
module.exports = (pool, query) => {
// Apply authentication for all admin upload routes
router.use('/admin', adminAuthMiddleware(pool, query));
// Upload product image (admin only)
router.post('/admin/products', upload.single('image'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({
error: true,
message: 'No image file provided'
});
}
// Get the relative path to the image
const imagePath = `/uploads/products/${req.file.filename}`;
res.json({
success: true,
imagePath,
filename: req.file.filename
});
} catch (error) {
next(error);
}
});
// Upload multiple product images (admin only)
router.post('/admin/products/multiple', upload.array('images', 10), async (req, res, next) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({
error: true,
message: 'No image files provided'
});
}
// Get the relative paths to the images
const imagePaths = req.files.map(file => ({
imagePath: `/uploads/products/${file.filename}`,
filename: file.filename
}));
res.json({
success: true,
images: imagePaths
});
} catch (error) {
next(error);
}
});
// Delete product image (admin only)
router.delete('/admin/products/:filename', async (req, res, next) => {
try {
const { filename } = req.params;
// Prevent path traversal attacks
if (filename.includes('..') || filename.includes('/')) {
return res.status(400).json({
error: true,
message: 'Invalid filename'
});
}
const filePath = path.join(__dirname, '../../public/uploads/products', filename);
// Check if file exists
if (!fs.existsSync(filePath)) {
return res.status(404).json({
error: true,
message: 'Image not found'
});
}
// Delete the file
fs.unlinkSync(filePath);
res.json({
success: true,
message: 'Image deleted successfully'
});
} catch (error) {
next(error);
}
});
// Upload blog post image (admin only)
router.post('/admin/blog', adminAuthMiddleware(pool, query), upload.single('image'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({
error: true,
message: 'No image file provided'
});
}
// Get the relative path to the image
const imagePath = `/uploads/blog/${req.file.filename}`;
res.json({
success: true,
imagePath,
filename: req.file.filename
});
} catch (error) {
next(error);
}
});
// Upload multiple blog images (admin only)
router.post('/admin/blog/multiple', adminAuthMiddleware(pool, query), upload.array('images', 10), async (req, res, next) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({
error: true,
message: 'No image files provided'
});
}
// Get the relative paths to the images
const imagePaths = req.files.map(file => ({
imagePath: `/uploads/blog/${file.filename}`,
filename: file.filename
}));
res.json({
success: true,
images: imagePaths
});
} catch (error) {
next(error);
}
});
// Delete blog image (admin only)
router.delete('/admin/blog/:filename', adminAuthMiddleware(pool, query), async (req, res, next) => {
try {
const { filename } = req.params;
// Prevent path traversal attacks
if (filename.includes('..') || filename.includes('/')) {
return res.status(400).json({
error: true,
message: 'Invalid filename'
});
}
const filePath = path.join(__dirname, '../../public/uploads/blog', filename);
// Check if file exists
if (!fs.existsSync(filePath)) {
return res.status(404).json({
error: true,
message: 'Image not found'
});
}
// Delete the file
fs.unlinkSync(filePath);
res.json({
success: true,
message: 'Image deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,724 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const multer = require('multer');
const csv = require('csv-parser');
const fs = require('fs');
const path = require('path');
const { createObjectCsvWriter } = require('csv-writer');
const storageService = require('../services/storageService');
// Configure multer for file uploads
// const upload = multer({
// dest: path.join(__dirname, '../uploads/temp'),
// limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
// });
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
const upload = storageService.getUploadMiddleware();
/**
* Get all mailing lists
* GET /api/admin/mailing-lists
*/
router.get('/', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all mailing lists with subscriber counts
const result = await query(`
SELECT
ml.id,
ml.name,
ml.description,
ml.created_at,
ml.updated_at,
COUNT(ms.subscriber_id) AS subscriber_count
FROM mailing_lists ml
LEFT JOIN mailing_list_subscribers ms ON ml.id = ms.list_id
GROUP BY
ml.id, ml.name, ml.description, ml.created_at, ml.updated_at
ORDER BY ml.name;
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
/**
* Get a single mailing list by ID
* GET /api/admin/mailing-lists/:id
*/
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get mailing list with subscriber count
const result = await query(`
SELECT
ml.id,
ml.name,
ml.description,
ml.created_at,
ml.updated_at,
COUNT(ms.subscriber_id) AS subscriber_count
FROM mailing_lists ml
LEFT JOIN mailing_list_subscribers ms ON ml.id = ms.list_id
WHERE ml.id = $1
GROUP BY
ml.id, ml.name, ml.description, ml.created_at, ml.updated_at;
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
res.json(result.rows[0]);
} catch (error) {
next(error);
}
});
/**
* Create a new mailing list
* POST /api/admin/mailing-lists
*/
router.post('/', async (req, res, next) => {
try {
const { name, description } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!name) {
return res.status(400).json({
error: true,
message: 'List name is required'
});
}
// Create new mailing list
const listId = uuidv4();
const result = await query(
`INSERT INTO mailing_lists (id, name, description)
VALUES ($1, $2, $3)
RETURNING *`,
[listId, name, description]
);
res.status(201).json(result.rows[0]);
} catch (error) {
next(error);
}
});
/**
* Update a mailing list
* PUT /api/admin/mailing-lists/:id
*/
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { name, description } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!name) {
return res.status(400).json({
error: true,
message: 'List name is required'
});
}
// Check if list exists
const listCheck = await query(
'SELECT id FROM mailing_lists WHERE id = $1',
[id]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
// Update mailing list
const result = await query(
`UPDATE mailing_lists
SET name = $1, description = $2, updated_at = NOW()
WHERE id = $3
RETURNING *`,
[name, description, id]
);
res.json(result.rows[0]);
} catch (error) {
next(error);
}
});
/**
* Delete a mailing list
* DELETE /api/admin/mailing-lists/:id
*/
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if list exists
const listCheck = await query(
'SELECT id FROM mailing_lists WHERE id = $1',
[id]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Delete all subscribers from this list
await client.query(
'DELETE FROM mailing_list_subscribers WHERE list_id = $1',
[id]
);
// Delete the mailing list
await client.query(
'DELETE FROM mailing_lists WHERE id = $1',
[id]
);
await client.query('COMMIT');
res.json({
success: true,
message: 'Mailing list deleted successfully'
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Get subscribers for a mailing list
* GET /api/admin/mailing-lists/:id/subscribers
*/
router.get('/:id/subscribers', async (req, res, next) => {
try {
const { id } = req.params;
const { page = 0, pageSize = 25, search = '', status } = req.query;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if list exists
const listCheck = await query(
'SELECT id FROM mailing_lists WHERE id = $1',
[id]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
// Prepare query conditions
const conditions = ['ms.list_id = $1'];
const queryParams = [id];
let paramIndex = 2;
if (search) {
conditions.push(`(
s.email ILIKE $${paramIndex} OR
s.first_name ILIKE $${paramIndex} OR
s.last_name ILIKE $${paramIndex}
)`);
queryParams.push(`%${search}%`);
paramIndex++;
}
if (status && status !== 'all') {
conditions.push(`s.status = $${paramIndex}`);
queryParams.push(status);
paramIndex++;
}
// Build WHERE clause
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM mailing_list_subscribers ms
JOIN subscribers s ON ms.subscriber_id = s.id
${whereClause}
`;
const countResult = await query(countQuery, queryParams);
const totalCount = parseInt(countResult.rows[0].total, 10);
// Get filtered count if status filter is applied
let filteredCount;
if (status && status !== 'all') {
const filteredCountQuery = `
SELECT COUNT(*) as filtered
FROM mailing_list_subscribers ms
JOIN subscribers s ON ms.subscriber_id = s.id
WHERE ms.list_id = $1 AND s.status = $2
`;
const filteredCountResult = await query(filteredCountQuery, [id, status]);
filteredCount = parseInt(filteredCountResult.rows[0].filtered, 10);
}
// Get subscribers with pagination
const offset = page * pageSize;
const subscribersQuery = `
SELECT
s.id,
s.email,
s.first_name,
s.last_name,
s.status,
ms.subscribed_at,
s.last_activity_at
FROM mailing_list_subscribers ms
JOIN subscribers s ON ms.subscriber_id = s.id
${whereClause}
ORDER BY s.email
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
queryParams.push(parseInt(pageSize, 10), offset);
const subscribersResult = await query(subscribersQuery, queryParams);
res.json({
subscribers: subscribersResult.rows,
totalCount,
filteredCount,
page: parseInt(page, 10),
pageSize: parseInt(pageSize, 10)
});
} catch (error) {
next(error);
}
});
/**
* Add a subscriber to a mailing list
* POST /api/admin/mailing-lists/:id/subscribers
*/
router.post('/:id/subscribers', async (req, res, next) => {
try {
const { id } = req.params;
const { email, firstName, lastName, status = 'active' } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!email) {
return res.status(400).json({
error: true,
message: 'Email is required'
});
}
// Check if list exists
const listCheck = await query(
'SELECT id FROM mailing_lists WHERE id = $1',
[id]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if subscriber already exists
let subscriberId;
const subscriberCheck = await client.query(
'SELECT id FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length > 0) {
// Update existing subscriber
subscriberId = subscriberCheck.rows[0].id;
await client.query(
`UPDATE subscribers
SET first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
status = $3,
updated_at = NOW()
WHERE id = $4`,
[firstName, lastName, status, subscriberId]
);
} else {
// Create new subscriber
subscriberId = uuidv4();
await client.query(
`INSERT INTO subscribers (id, email, first_name, last_name, status)
VALUES ($1, $2, $3, $4, $5)`,
[subscriberId, email, firstName, lastName, status]
);
}
// Check if subscriber is already on this list
const listSubscriberCheck = await client.query(
'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2',
[id, subscriberId]
);
if (listSubscriberCheck.rows.length === 0) {
// Add subscriber to list
await client.query(
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id)
VALUES ($1, $2)`,
[id, subscriberId]
);
}
await client.query('COMMIT');
res.status(201).json({
success: true,
message: 'Subscriber added to list',
subscriberId,
listId: id
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Import subscribers to a mailing list from JSON data
* POST /api/admin/mailing-lists/import
*/
router.post('/import', async (req, res, next) => {
try {
const { listId, subscribers } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
if (!listId) {
return res.status(400).json({
error: true,
message: 'List ID is required'
});
}
if (!subscribers || !Array.isArray(subscribers) || subscribers.length === 0) {
return res.status(400).json({
error: true,
message: 'Subscribers array is required and cannot be empty'
});
}
// Check if list exists
const listCheck = await query(
'SELECT id FROM mailing_lists WHERE id = $1',
[listId]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
// Begin transaction
const client = await pool.connect();
let importedCount = 0;
let errorCount = 0;
const errors = [];
const imported = [];
try {
await client.query('BEGIN');
// Process subscribers in batches to avoid overwhelming the database
const batchSize = 50;
for (let i = 0; i < subscribers.length; i += batchSize) {
const batch = subscribers.slice(i, i + batchSize);
// Process each subscriber in the batch
for (const data of batch) {
try {
// Validate email
const email = data.email;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errorCount++;
errors.push({
email: email || 'Invalid email',
reason: 'Invalid email format'
});
continue;
}
// Check if subscriber already exists
let subscriberId;
const subscriberCheck = await client.query(
'SELECT id FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length > 0) {
// Update existing subscriber
subscriberId = subscriberCheck.rows[0].id;
await client.query(
`UPDATE subscribers
SET first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
status = COALESCE($3, status),
updated_at = NOW()
WHERE id = $4`,
[data.first_name, data.last_name, data.status || 'active', subscriberId]
);
} else {
// Create new subscriber
subscriberId = uuidv4();
await client.query(
`INSERT INTO subscribers (id, email, first_name, last_name, status)
VALUES ($1, $2, $3, $4, $5)`,
[subscriberId, email, data.first_name || null, data.last_name || null, data.status || 'active']
);
}
// Check if subscriber is already on this list
const listSubscriberCheck = await client.query(
'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2',
[listId, subscriberId]
);
if (listSubscriberCheck.rows.length === 0) {
// Add subscriber to list
await client.query(
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id)
VALUES ($1, $2)`,
[listId, subscriberId]
);
}
importedCount++;
imported.push({
email,
first_name: data.first_name || null,
last_name: data.last_name || null,
status: data.status || 'active'
});
} catch (err) {
console.error('Error importing subscriber:', err);
errorCount++;
errors.push({
email: data.email || 'Unknown',
reason: err.message || 'Database error'
});
}
}
}
await client.query('COMMIT');
res.status(200).json({
success: true,
message: `Import completed with ${importedCount} subscribers added and ${errorCount} errors`,
importedCount,
errorCount,
imported,
errors,
listId
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Export subscribers from a mailing list as Excel
* GET /api/admin/mailing-lists/:id/export
*/
router.get('/:id/export', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if list exists
const listCheck = await query(
'SELECT name FROM mailing_lists WHERE id = $1',
[id]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
const listName = listCheck.rows[0].name;
// Get all subscribers for this list
const subscribersQuery = `
SELECT
s.email,
s.first_name,
s.last_name,
s.status,
ms.subscribed_at,
s.last_activity_at
FROM mailing_list_subscribers ms
JOIN subscribers s ON ms.subscriber_id = s.id
WHERE ms.list_id = $1
ORDER BY s.email
`;
const subscribersResult = await query(subscribersQuery, [id]);
const subscribers = subscribersResult.rows;
// Create Excel workbook and worksheet
const XLSX = require('xlsx');
const workbook = XLSX.utils.book_new();
// Format subscribers data for Excel
const formattedSubscribers = subscribers.map(sub => ({
Email: sub.email,
'First Name': sub.first_name || '',
'Last Name': sub.last_name || '',
Status: sub.status || 'active',
'Subscribed At': sub.subscribed_at ? new Date(sub.subscribed_at).toISOString() : '',
'Last Activity': sub.last_activity_at ? new Date(sub.last_activity_at).toISOString() : ''
}));
// Create worksheet
const worksheet = XLSX.utils.json_to_sheet(formattedSubscribers);
// Set column widths for better readability
const colWidths = [
{ wch: 30 }, // Email
{ wch: 15 }, // First Name
{ wch: 15 }, // Last Name
{ wch: 10 }, // Status
{ wch: 25 }, // Subscribed At
{ wch: 25 } // Last Activity
];
worksheet['!cols'] = colWidths;
// Add worksheet to workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Subscribers');
// Generate timestamp for filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.xlsx`;
// Set headers for Excel download
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
// Generate and send Excel file
const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
res.send(excelBuffer);
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,599 +0,0 @@
const express = require('express');
const router = express.Router();
const emailService = require('../services/emailService'); // Import email service
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all orders (admin only)
router.get('/', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
const result = await query(`
SELECT o.*,
u.email, u.first_name, u.last_name,
COUNT(oi.id) AS item_count
FROM orders o
JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON o.id = oi.order_id
GROUP BY o.id, u.id
ORDER BY o.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get single order with items
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get order details with shipping information
const orderResult = await query(`
SELECT o.*,
u.email,
u.first_name,
u.last_name,
(SELECT EXISTS(
SELECT 1 FROM notification_logs
WHERE order_id = o.id AND notification_type = 'shipping_notification'
)) as notification_sent
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = $1
`, [id]);
if (orderResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Order not found'
});
}
// Get order items with product details
const itemsResult = await query(`
SELECT oi.*,
p.name as product_name,
p.description as product_description,
pc.name as product_category,
(
SELECT json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
)
FROM product_images pi
WHERE pi.product_id = p.id
) AS product_images
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE oi.order_id = $1
`, [id]);
// Combine order with items
const order = {
...orderResult.rows[0],
items: itemsResult.rows
};
res.json(order);
} catch (error) {
next(error);
}
});
// Update order status with refund process
router.patch('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { status, refundReason } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate status
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
if (!validStatuses.includes(status)) {
return res.status(400).json({
error: true,
message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`
});
}
// Get order with customer information before updating
const orderResult = await query(`
SELECT o.*, o.payment_id, o.total_amount, u.email, u.first_name, u.last_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = $1
`, [id]);
if (orderResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Order not found'
});
}
const order = orderResult.rows[0];
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Special handling for refunded status
if (status === 'refunded' && order.status !== 'refunded') {
// Check if payment_id exists
if (!order.payment_id) {
await client.query('ROLLBACK');
return res.status(400).json({
error: true,
message: 'Cannot refund order without payment information'
});
}
try {
// Process the refund with Stripe
const stripe = require('stripe')(config.payment.stripeSecretKey);
// If the payment ID is a Stripe Checkout session ID, get the associated payment intent
let paymentIntentId = order.payment_id;
// If it starts with 'cs_', it's a checkout session
if (paymentIntentId.startsWith('cs_')) {
const session = await stripe.checkout.sessions.retrieve(paymentIntentId);
paymentIntentId = session.payment_intent;
}
// Create the refund
const refund = await stripe.refunds.create({
payment_intent: paymentIntentId,
reason: 'requested_by_customer',
});
// Store refund information in the order
const refundInfo = {
refund_id: refund.id,
amount: refund.amount / 100, // Convert from cents to dollars
status: refund.status,
reason: refundReason || 'Customer request',
created: new Date(refund.created * 1000).toISOString(),
};
// Update order with refund information
await client.query(`
UPDATE orders
SET status = $1,
updated_at = NOW(),
payment_notes = CASE
WHEN payment_notes IS NULL THEN $2::jsonb
ELSE payment_notes::jsonb || $2::jsonb
END
WHERE id = $3
RETURNING *
`, [
status,
JSON.stringify({ refund: refundInfo }),
id
]);
// Send refund confirmation email
await emailService.sendRefundConfirmation({
to: order.email,
first_name: order.first_name || 'Customer',
order_id: order.id.substring(0, 8), // Use first 8 characters of UUID for cleaner display
refund_amount: `$${refundInfo.amount.toFixed(2)}`,
refund_date: new Date(refundInfo.created).toLocaleDateString(),
refund_method: 'Original payment method',
refund_reason: refundInfo.reason
});
} catch (stripeError) {
console.error('Stripe refund error:', stripeError);
await client.query('ROLLBACK');
return res.status(400).json({
error: true,
message: `Failed to process refund: ${stripeError.message}`
});
}
} else {
// For non-refund status updates, just update the status
await client.query(`
UPDATE orders
SET status = $1, updated_at = NOW()
WHERE id = $2
`, [status, id]);
}
await client.query('COMMIT');
// Get updated order
const updatedOrderResult = await query(
'SELECT * FROM orders WHERE id = $1',
[id]
);
res.json({
message: 'Order status updated successfully',
order: updatedOrderResult.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
router.post('/:id/refund', async (req, res, next) => {
try {
const { id } = req.params;
const {
amount,
reason,
refundItems, // Optional: array of { item_id, quantity } for partial refunds
sendEmail = true // Whether to send confirmation email
} = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get order details with customer information
const orderResult = await query(`
SELECT o.*, o.payment_id, o.total_amount, u.email, u.first_name, u.last_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = $1
`, [id]);
if (orderResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Order not found'
});
}
const order = orderResult.rows[0];
// Check if payment_id exists
if (!order.payment_id) {
return res.status(400).json({
error: true,
message: 'Cannot refund order without payment information'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Determine if this is a full or partial refund
const refundAmount = amount || order.total_amount;
const isFullRefund = Math.abs(refundAmount - order.total_amount) < 0.01; // Account for possible floating point issues
try {
// Process the refund with Stripe
const stripe = require('stripe')(config.payment.stripeSecretKey);
// If the payment ID is a Stripe Checkout session ID, get the associated payment intent
let paymentIntentId = order.payment_id;
// If it starts with 'cs_', it's a checkout session
if (paymentIntentId.startsWith('cs_')) {
const session = await stripe.checkout.sessions.retrieve(paymentIntentId);
paymentIntentId = session.payment_intent;
}
// Create the refund
const refund = await stripe.refunds.create({
payment_intent: paymentIntentId,
amount: Math.round(refundAmount * 100), // Convert to cents for Stripe
reason: 'requested_by_customer',
});
// Store refund information in the order
const refundInfo = {
refund_id: refund.id,
amount: refund.amount / 100, // Convert from cents to dollars
status: refund.status,
reason: reason || 'Customer request',
created: new Date(refund.created * 1000).toISOString(),
is_full_refund: isFullRefund,
refunded_items: refundItems || []
};
// Update order status and refund info
if (isFullRefund) {
// Full refund changes order status to refunded
await client.query(`
UPDATE orders
SET status = 'refunded',
updated_at = NOW(),
payment_notes = CASE
WHEN payment_notes IS NULL THEN $1::jsonb
ELSE payment_notes::jsonb || $1::jsonb
END
WHERE id = $2
`, [
JSON.stringify({ refund: refundInfo }),
id
]);
} else {
// Partial refund doesn't change order status
await client.query(`
UPDATE orders
SET updated_at = NOW(),
payment_notes = CASE
WHEN payment_notes IS NULL THEN $1::jsonb
ELSE payment_notes::jsonb || $1::jsonb
END
WHERE id = $2
`, [
JSON.stringify({ partial_refund: refundInfo }),
id
]);
}
// If specific items were refunded, update their refund status in order_items
if (refundItems && refundItems.length > 0) {
for (const item of refundItems) {
await client.query(`
UPDATE order_items
SET refunded = true, refunded_quantity = $1
WHERE id = $2 AND order_id = $3
`, [
item.quantity,
item.item_id,
id
]);
}
}
// Send refund confirmation email if requested
if (sendEmail) {
await emailService.sendRefundConfirmation({
to: order.email,
first_name: order.first_name || 'Customer',
order_id: order.id.substring(0, 8), // Use first 8 characters of UUID for cleaner display
refund_amount: `$${refundInfo.amount.toFixed(2)}`,
refund_date: new Date(refundInfo.created).toLocaleDateString(),
refund_method: 'Original payment method',
refund_reason: refundInfo.reason
});
}
await client.query('COMMIT');
// Get the updated order
const updatedOrderResult = await query(
'SELECT * FROM orders WHERE id = $1',
[id]
);
res.json({
success: true,
message: `${isFullRefund ? 'Full' : 'Partial'} refund processed successfully`,
refund: refundInfo,
order: updatedOrderResult.rows[0]
});
} catch (stripeError) {
console.error('Stripe refund error:', stripeError);
await client.query('ROLLBACK');
return res.status(400).json({
error: true,
message: `Failed to process refund: ${stripeError.message}`
});
}
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
// Update order status with shipping information and send notification
router.patch('/:id/shipping', async (req, res, next) => {
try {
const { id } = req.params;
const { status, shippingData, sendNotification } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate status
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
if (!validStatuses.includes(status)) {
return res.status(400).json({
error: true,
message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`
});
}
// Get order with customer information before updating
const orderResult = await query(`
SELECT o.*, u.email, u.first_name, u.last_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = $1
`, [id]);
if (orderResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Order not found'
});
}
const order = orderResult.rows[0];
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Store shipping information as JSON in the database
const updatedOrder = await client.query(`
UPDATE orders
SET
status = $1,
updated_at = NOW(),
shipping_info = $2,
shipping_date = $3
WHERE id = $4
RETURNING *
`, [
status,
JSON.stringify(shippingData),
new Date(),
id
]);
// If status is 'shipped' and notification requested, send email
if (status === 'shipped' && sendNotification) {
// Get order items for the email
const itemsResult = await client.query(`
SELECT oi.*, p.name as product_name, p.price as original_price
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = $1
`, [id]);
const orderItems = itemsResult.rows;
// Generate items HTML table
const itemsHtml = orderItems.map(item => `
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
</tr>
`).join('');
// Generate carrier tracking link
let trackingLink = '#';
const shipper = shippingData.shipper || '';
const trackingNumber = shippingData.trackingNumber;
if (trackingNumber) {
// Match exactly with the values from the dropdown
switch(shipper) {
case 'USPS':
trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`;
break;
case 'UPS':
trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`;
break;
case 'FedEx':
trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`;
break;
case 'DHL':
trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`;
break;
case 'Canada Post':
trackingLink = `https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=${trackingNumber}`;
break;
case 'Purolator':
trackingLink = `https://www.purolator.com/en/shipping/track/tracking-number/${trackingNumber}`;
break;
default:
// For "other" or any carrier not in our list
// Just make the tracking number text without a link
trackingLink = '#';
break;
}
}
// Format shipping date
const shippedDate = new Date(shippingData.shippedDate || new Date()).toLocaleDateString();
// Send email notification using template system
await emailService.sendShippingNotification({
to: order.email,
first_name: order.first_name,
order_id: order.id.substring(0, 8),
tracking_number: shippingData.trackingNumber || 'N/A',
carrier: shippingData.shipper || 'Standard Shipping',
tracking_link: trackingLink,
shipped_date: shippedDate,
estimated_delivery: shippingData.estimatedDelivery || 'N/A',
items_html: itemsHtml,
customer_message: shippingData.customerMessage || ''
});
// Log the notification in the database
await client.query(`
INSERT INTO notification_logs (order_id, notification_type, sent_at)
VALUES ($1, $2, NOW())
`, [id, 'shipping_notification']);
}
await client.query('COMMIT');
res.json({
message: 'Order status updated successfully',
order: updatedOrder.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
console.error('Error updating order status with shipping:', error);
next(error);
}
});
return router;
};

View file

@ -1,597 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Create a new product with multiple images
router.post('/', async (req, res, next) => {
try {
// Check for array
let products = [];
if (req.body.products) {
products = req.body.products;
} else {
const {
name,
description,
categoryName,
price,
stockQuantity,
weightGrams,
lengthCm,
widthCm,
heightCm,
stockNotification,
origin,
age,
materialType,
color,
images,
tags
} = req.body;
products.push({
name,
description,
categoryName,
price,
stockQuantity,
weightGrams,
lengthCm,
widthCm,
heightCm,
stockNotification,
origin,
age,
materialType,
color,
images: images || [],
tags
});
}
// Prepare promises array for concurrent execution
const productPromises = products.map(product => createProduct(product, pool));
// Execute all product creation promises concurrently
const results = await Promise.all(productPromises);
// Separate successes and errors
const success = results.filter(result => !result.error).map(result => ({
message: 'Product created successfully',
product: result.product,
code: 201
}));
const errs = results.filter(result => result.error);
res.status(201).json({
message: errs.length > 0 ?
(success.length > 0 ? 'Some Products failed to create, check errors array' : "Failed to create Products") :
'Products created successfully',
success,
errs
});
} catch (error) {
next(error);
}
});
router.post('/:id/stock-notification', async (req, res, next) => {
const client = await pool.connect();
try {
const { id } = req.params;
const { enabled, email, threshold } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
await client.query('BEGIN');
// Check if product exists
const productCheck = await query(
'SELECT * FROM products WHERE id = $1',
[id]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
// Store notification settings as JSONB
const notificationSettings = {
enabled,
email: email || null,
threshold: threshold || 0
};
// Update product with notification settings
const result = await addThresholdNotification(id, enabled, email, threshold, client);
await client.query('COMMIT');
res.json({
message: 'Stock notification settings updated successfully',
product: result.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
next(error);
} finally {
client.release();
}
});
// Update an existing product
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const {
name,
description,
categoryName,
price,
stockQuantity,
weightGrams,
lengthCm,
stockNotification,
widthCm,
heightCm,
origin,
age,
materialType,
color,
images,
tags
} = req.body;
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if product exists
const productCheck = await client.query(
'SELECT * FROM products WHERE id = $1',
[id]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
// If category is changing, get the new category ID
let categoryId = productCheck.rows[0].category_id;
if (categoryName) {
const categoryResult = await client.query(
'SELECT id FROM product_categories WHERE name = $1',
[categoryName]
);
if (categoryResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: `Category "${categoryName}" not found`
});
}
categoryId = categoryResult.rows[0].id;
}
// Update product
const updateFields = [];
const updateValues = [];
let valueIndex = 1;
if (name) {
updateFields.push(`name = $${valueIndex}`);
updateValues.push(name);
valueIndex++;
}
if (description) {
updateFields.push(`description = $${valueIndex}`);
updateValues.push(description);
valueIndex++;
}
if (categoryName) {
updateFields.push(`category_id = $${valueIndex}`);
updateValues.push(categoryId);
valueIndex++;
}
if (price) {
updateFields.push(`price = $${valueIndex}`);
updateValues.push(price);
valueIndex++;
}
if (stockQuantity !== undefined) {
updateFields.push(`stock_quantity = $${valueIndex}`);
updateValues.push(stockQuantity);
valueIndex++;
}
if (weightGrams !== undefined) {
updateFields.push(`weight_grams = $${valueIndex}`);
updateValues.push(weightGrams);
valueIndex++;
}
if (lengthCm !== undefined) {
updateFields.push(`length_cm = $${valueIndex}`);
updateValues.push(lengthCm);
valueIndex++;
}
if (widthCm !== undefined) {
updateFields.push(`width_cm = $${valueIndex}`);
updateValues.push(widthCm);
valueIndex++;
}
if (heightCm !== undefined) {
updateFields.push(`height_cm = $${valueIndex}`);
updateValues.push(heightCm);
valueIndex++;
}
if (origin !== undefined) {
updateFields.push(`origin = $${valueIndex}`);
updateValues.push(origin);
valueIndex++;
}
if (age !== undefined) {
updateFields.push(`age = $${valueIndex}`);
updateValues.push(age);
valueIndex++;
}
if (materialType !== undefined) {
updateFields.push(`material_type = $${valueIndex}`);
updateValues.push(materialType);
valueIndex++;
}
if (color !== undefined) {
updateFields.push(`color = $${valueIndex}`);
updateValues.push(color);
valueIndex++;
}
if (stockNotification) {
await addThresholdNotification(id, stockNotification.enabled, stockNotification.email, stockNotification.threshold, client);
}
if (updateFields.length > 0) {
const updateQuery = `
UPDATE products
SET ${updateFields.join(', ')}
WHERE id = $${valueIndex}
`;
updateValues.push(id);
await client.query(updateQuery, updateValues);
}
// Update images if provided
if (images) {
// Remove existing images
await client.query(
'DELETE FROM product_images WHERE product_id = $1',
[id]
);
// Add new images
for (let i = 0; i < images.length; i++) {
const { path, isPrimary = (i === 0) } = images[i];
await client.query(
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
[id, path, i, isPrimary]
);
}
}
// Update tags if provided
if (tags) {
// Remove existing tags
await client.query(
'DELETE FROM product_tags WHERE product_id = $1',
[id]
);
// Add new tags
for (const tagName of tags) {
// Get tag ID
let tagResult = await client.query(
'SELECT id FROM tags WHERE name = $1',
[tagName]
);
let tagId;
// If tag doesn't exist, create it
if (tagResult.rows.length === 0) {
const newTagResult = await client.query(
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
[tagName]
);
tagId = newTagResult.rows[0].id;
} else {
tagId = tagResult.rows[0].id;
}
// Add tag to product
await client.query(
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
[id, tagId]
);
}
}
await client.query('COMMIT');
// Get updated product
const productQuery = `
SELECT p.*,
pc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
json_agg(json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
)) FILTER (WHERE pi.id IS NOT NULL) AS images
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_tags pt ON p.id = pt.product_id
LEFT JOIN tags t ON pt.tag_id = t.id
LEFT JOIN product_images pi ON p.id = pi.product_id
WHERE p.id = $1
GROUP BY p.id, pc.name
`;
const product = await query(productQuery, [id]);
res.json({
message: 'Product updated successfully',
product: product.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
// Delete a product
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// Check if product exists
const productCheck = await query(
'SELECT * FROM products WHERE id = $1',
[id]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
// Delete product (cascade will handle related records)
await query(
'DELETE FROM products WHERE id = $1',
[id]
);
res.json({
message: 'Product deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};
async function addThresholdNotification(id, enabled, email, threshold ,client) {
// Store notification settings as JSONB
const notificationSettings = {
enabled,
email: email || null,
threshold: threshold || 0
};
// Update product with notification settings
const result = await client.query(
`UPDATE products
SET stock_notification = $1
WHERE id = $2
RETURNING *`,
[JSON.stringify(notificationSettings), id]
);
return result;
}
async function createProduct(product, pool) {
const {
name,
description,
categoryName,
price,
stockQuantity,
weightGrams,
lengthCm,
widthCm,
heightCm,
stockNotification,
origin,
age,
materialType,
color,
images,
tags
} = product;
// Validate required fields
if (!name || !description || !categoryName || !price || !stockQuantity) {
return {
error: true,
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory',
code: 400
};
}
// Get a client from the pool for this specific product
const client = await pool.connect();
try {
await client.query('BEGIN');
// Get category ID by name
const categoryResult = await client.query(
'SELECT id FROM product_categories WHERE name = $1',
[categoryName]
);
if (categoryResult.rows.length === 0) {
return {
error: true,
message: `Category "${categoryName}" not found`,
code: 404
};
}
const categoryId = categoryResult.rows[0].id;
// Create product
const productId = uuidv4();
await client.query(
`INSERT INTO products (
id, name, description, category_id, price, stock_quantity,
weight_grams, length_cm, width_cm, height_cm,
origin, age, material_type, color
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
[
productId, name, description, categoryId, price, stockQuantity,
weightGrams || null, lengthCm || null, widthCm || null, heightCm || null,
origin || null, age || null, materialType || null, color || null
]
);
// Add images if provided
if (images && images.length > 0) {
const imagePromises = images.map((image, i) => {
const { path, isPrimary = (i === 0) } = image;
return client.query(
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
[productId, path, i, isPrimary]
);
});
await Promise.all(imagePromises);
}
// Add tags if provided
if (tags && tags.length > 0) {
for (const tagName of tags) {
// Get tag ID
let tagResult = await client.query(
'SELECT id FROM tags WHERE name = $1',
[tagName]
);
let tagId;
// If tag doesn't exist, create it
if (tagResult.rows.length === 0) {
const newTagResult = await client.query(
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
[tagName]
);
tagId = newTagResult.rows[0].id;
} else {
tagId = tagResult.rows[0].id;
}
// Add tag to product
await client.query(
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
[productId, tagId]
);
}
}
// Get complete product with images and tags
const productQuery = `
SELECT p.*,
pc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
json_agg(json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
)) FILTER (WHERE pi.id IS NOT NULL) AS images
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_tags pt ON p.id = pt.product_id
LEFT JOIN tags t ON pt.tag_id = t.id
LEFT JOIN product_images pi ON p.id = pi.product_id
WHERE p.id = $1
GROUP BY p.id, pc.name
`;
const productResult = await client.query(productQuery, [productId]);
// Add threshold notification if provided
if (stockNotification) {
await addThresholdNotification(productId, stockNotification.enabled, stockNotification.email, stockNotification.threshold, client);
}
await client.query('COMMIT');
return {
error: false,
product: productResult.rows[0]
};
} catch (error) {
await client.query('ROLLBACK');
return {
error: true,
message: `Error creating product: ${error.message}`,
code: 500
};
} finally {
client.release();
}
}

View file

@ -1,41 +0,0 @@
/**
* Process image paths and prepare them for database storage
* @param {Array} images - Array of image objects with paths
* @returns {Array} - Processed image objects with proper paths
*/
const processImagePaths = (images) => {
if (!images || !Array.isArray(images)) {
return [];
}
return images.map((image, index) => {
// If the image is already a string (existing path), use it as is
if (typeof image === 'string') {
return {
path: image,
isPrimary: index === 0, // First image is primary by default
displayOrder: index
};
}
// If the image is an object from the upload middleware
if (image.path || image.imagePath) {
return {
path: image.imagePath || image.path,
isPrimary: image.isPrimary || index === 0,
displayOrder: image.displayOrder || index
};
}
// If the image is from the frontend (already has path field)
return {
path: image.path,
isPrimary: image.isPrimary || index === 0,
displayOrder: image.displayOrder || index
};
});
};
module.exports = {
processImagePaths
};

View file

@ -1,246 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get reviews for a specific product
router.get('/:productId', async (req, res, next) => {
try {
const { productId } = req.params;
// Get all approved reviews with user info and replies
const reviewsQuery = `
SELECT
r.id, r.title, r.content, r.rating, r.is_verified_purchase,
r.created_at, r.parent_id,
u.id as user_id, u.first_name, u.last_name
FROM product_reviews r
JOIN users u ON r.user_id = u.id
WHERE r.product_id = $1 AND r.is_approved = true
ORDER BY r.created_at DESC
`;
const reviewsResult = await query(reviewsQuery, [productId]);
// Organize reviews into threads
const reviewThreads = [];
const reviewMap = {};
// First, create a map of all reviews
reviewsResult.rows.forEach(review => {
reviewMap[review.id] = {
...review,
replies: []
};
});
// Then, organize into threads
reviewsResult.rows.forEach(review => {
if (review.parent_id) {
// This is a reply
if (reviewMap[review.parent_id]) {
reviewMap[review.parent_id].replies.push(reviewMap[review.id]);
}
} else {
// This is a top-level review
reviewThreads.push(reviewMap[review.id]);
}
});
res.json(reviewThreads);
} catch (error) {
next(error);
}
});
// Add a review to a product
router.post('/:productId', async (req, res, next) => {
try {
const { productId } = req.params;
const { title, content, rating, parentId } = req.body;
const userId = req.user.id;
// Validate required fields
if (!title) {
return res.status(400).json({
error: true,
message: 'Review title is required'
});
}
// If it's a top-level review, rating is required
if (!parentId && (!rating || rating < 1 || rating > 5)) {
return res.status(400).json({
error: true,
message: 'Valid rating (1-5) is required for reviews'
});
}
// Check if product exists
const productCheck = await query(
'SELECT id FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
// If this is a reply, check if parent review exists
if (parentId) {
const parentCheck = await query(
'SELECT id FROM product_reviews WHERE id = $1 AND product_id = $2',
[parentId, productId]
);
if (parentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Parent review not found'
});
}
}
// Check if user has purchased the product
const purchaseCheck = await query(`
SELECT o.id
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = $1
AND oi.product_id = $2
AND o.payment_completed = true
LIMIT 1
`, [userId, productId]);
const isVerifiedPurchase = purchaseCheck.rows.length > 0;
const isAdmin = req.user.is_admin || false;
// Only allow reviews if user has purchased the product or is an admin
if (!isVerifiedPurchase && !isAdmin && !parentId) {
return res.status(403).json({
error: true,
message: 'You must purchase this product before you can review it'
});
}
// For replies, we don't need verified purchase
const isApproved = isAdmin ? true : false; // Auto-approve admin reviews
const reviewData = {
id: uuidv4(),
productId,
userId,
parentId: parentId || null,
title,
content: content || null,
rating: parentId ? null : rating,
isApproved,
isVerifiedPurchase: parentId ? null : isVerifiedPurchase
};
// Insert review
const insertQuery = `
INSERT INTO product_reviews
(id, product_id, user_id, parent_id, title, content, rating, is_approved, is_verified_purchase)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const result = await query(
insertQuery,
[
reviewData.id,
reviewData.productId,
reviewData.userId,
reviewData.parentId,
reviewData.title,
reviewData.content,
reviewData.rating,
reviewData.isApproved,
reviewData.isVerifiedPurchase
]
);
// Get user info for the response
const userResult = await query(
'SELECT first_name, last_name FROM users WHERE id = $1',
[userId]
);
const review = {
...result.rows[0],
first_name: userResult.rows[0].first_name,
last_name: userResult.rows[0].last_name,
replies: []
};
res.status(201).json({
message: isApproved
? 'Review added successfully'
: 'Review submitted and awaiting approval',
review
});
} catch (error) {
next(error);
}
});
// Check if user can review a product
router.get('/:productId/can-review', async (req, res, next) => {
try {
const { productId } = req.params;
const userId = req.user.id;
const isAdmin = req.user.is_admin || false;
// Check if user has already reviewed this product
const existingReviewCheck = await query(`
SELECT id FROM product_reviews
WHERE product_id = $1 AND user_id = $2 AND parent_id IS NULL
LIMIT 1
`, [productId, userId]);
if (existingReviewCheck.rows.length > 0) {
return res.json({
canReview: false,
reason: 'You have already reviewed this product'
});
}
// If user is admin, they can always review
if (isAdmin) {
return res.json({
canReview: true,
isAdmin: true
});
}
// Check if user has purchased the product
const purchaseCheck = await query(`
SELECT o.id
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = $1
AND oi.product_id = $2
AND o.payment_completed = true
LIMIT 1
`, [userId, productId]);
res.json({
canReview: purchaseCheck.rows.length > 0,
isPurchaser: purchaseCheck.rows.length > 0,
isAdmin: false
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,208 +0,0 @@
const express = require('express');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all pending reviews (admin)
router.get('/pending', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all pending reviews with related data
const result = await query(`
SELECT
r.id, r.title, r.content, r.rating, r.created_at,
r.parent_id, r.product_id, r.user_id, r.is_verified_purchase,
u.first_name, u.last_name, u.email,
p.name as product_name
FROM product_reviews r
JOIN users u ON r.user_id = u.id
JOIN products p ON r.product_id = p.id
WHERE r.is_approved = false
ORDER BY r.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get all reviews for a product (admin)
router.get('/products/:productId', async (req, res, next) => {
try {
const { productId } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if product exists
const productCheck = await query(
'SELECT id, name FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
const product = productCheck.rows[0];
// Get all reviews for the product
const reviewsQuery = `
SELECT
r.id, r.title, r.content, r.rating, r.created_at,
r.parent_id, r.is_approved, r.is_verified_purchase,
u.id as user_id, u.first_name, u.last_name, u.email
FROM product_reviews r
JOIN users u ON r.user_id = u.id
WHERE r.product_id = $1
ORDER BY r.created_at DESC
`;
const reviewsResult = await query(reviewsQuery, [productId]);
// Organize reviews into threads
const reviewThreads = [];
const reviewMap = {};
// First, create a map of all reviews
reviewsResult.rows.forEach(review => {
reviewMap[review.id] = {
...review,
replies: []
};
});
// Then, organize into threads
reviewsResult.rows.forEach(review => {
if (review.parent_id) {
// This is a reply
if (reviewMap[review.parent_id]) {
reviewMap[review.parent_id].replies.push(reviewMap[review.id]);
}
} else {
// This is a top-level review
reviewThreads.push(reviewMap[review.id]);
}
});
res.json({
product: {
id: product.id,
name: product.name
},
reviews: reviewThreads
});
} catch (error) {
next(error);
}
});
// Approve a review
router.post('/:reviewId/approve', async (req, res, next) => {
try {
const { reviewId } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if review exists
const reviewCheck = await query(
'SELECT id, is_approved FROM product_reviews WHERE id = $1',
[reviewId]
);
if (reviewCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Review not found'
});
}
// Check if review is already approved
if (reviewCheck.rows[0].is_approved) {
return res.status(400).json({
error: true,
message: 'Review is already approved'
});
}
// Approve review
const result = await query(
'UPDATE product_reviews SET is_approved = true WHERE id = $1 RETURNING *',
[reviewId]
);
res.json({
message: 'Review approved successfully',
review: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Delete a review
router.delete('/:reviewId', async (req, res, next) => {
try {
const { reviewId } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if review exists
const reviewCheck = await query(
'SELECT id FROM product_reviews WHERE id = $1',
[reviewId]
);
if (reviewCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Review not found'
});
}
// Delete review and all replies (cascading delete handles this)
await query(
'DELETE FROM product_reviews WHERE id = $1',
[reviewId]
);
res.json({
message: 'Review deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,220 +0,0 @@
const express = require('express');
const router = express.Router();
module.exports = (pool, qry) => {
// Get all products
router.get('/', async (req, res, next) => {
try {
const { category, tag, search, sort, order } = req.query;
let query = `
SELECT p.*, pc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
) FILTER (WHERE pi.id IS NOT NULL) AS images
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_tags pt ON p.id = pt.product_id
LEFT JOIN tags t ON pt.tag_id = t.id
LEFT JOIN product_images pi ON p.id = pi.product_id
`;
const whereConditions = [];
const params = [];
let paramIndex = 1;
// Filter by category
if (category) {
params.push(category);
whereConditions.push(`pc.name = $${params.length}`);
paramIndex++;
}
// Filter by tag
if (tag) {
params.push(tag);
whereConditions.push(`t.name = $${params.length}`);
paramIndex++;
}
// Search functionality
if (search) {
params.push(`%${search}%`);
whereConditions.push(`(p.name ILIKE $${params.length} OR p.description ILIKE $${params.length})`);
paramIndex++;
}
// Add WHERE clause if we have conditions
if (whereConditions.length > 0) {
query += ` WHERE ${whereConditions.join(' AND ')}`;
}
// Group by product id
query += ` GROUP BY p.id, pc.name`;
// Sorting
if (sort) {
const validSortColumns = ['name', 'price', 'created_at'];
const validOrderDirections = ['asc', 'desc'];
const sortColumn = validSortColumns.includes(sort) ? sort : 'name';
const orderDirection = validOrderDirections.includes(order) ? order : 'asc';
query += ` ORDER BY p.${sortColumn} ${orderDirection}`;
} else {
// Default sort
query += ` ORDER BY p.name ASC`;
}
const result = await qry(query, params);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get single product by ID or IDS if multiple are passed in as a comma seperated string
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// Check if comma is present in the ID parameter
if (id.includes(',')) {
// Handle multiple product IDs
const productIds = id.split(',').map(item => item.trim());
const placeholders = productIds.map((_, index) => `$${index + 1}`).join(',');
const query = `
SELECT p.*, pc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
) FILTER (WHERE pi.id IS NOT NULL) AS images
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_tags pt ON p.id = pt.product_id
LEFT JOIN tags t ON pt.tag_id = t.id
LEFT JOIN product_images pi ON p.id = pi.product_id
WHERE p.id IN (${placeholders})
GROUP BY p.id, pc.name
`;
const result = await qry(query, productIds);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'No products found'
});
}
// Return array of products
res.json(result.rows);
} else {
// Handle single product ID (original code)
const query = `
SELECT p.*, pc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
) FILTER (WHERE pi.id IS NOT NULL) AS images
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_tags pt ON p.id = pt.product_id
LEFT JOIN tags t ON pt.tag_id = t.id
LEFT JOIN product_images pi ON p.id = pi.product_id
WHERE p.id = $1
GROUP BY p.id, pc.name
`;
const result = await qry(query, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
// Return single product
res.json([result.rows[0]]);
}
} catch (error) {
next(error);
}
});
// Get all product categories
router.get('/categories/all', async (req, res, next) => {
try {
const result = await qry('SELECT * FROM product_categories');
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get all tags
router.get('/tags/all', async (req, res, next) => {
try {
const result = await qry('SELECT * FROM tags');
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get products by category
router.get('/category/:categoryName', async (req, res, next) => {
try {
const { categoryName } = req.params;
const query = `
SELECT p.*,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
) FILTER (WHERE pi.id IS NOT NULL) AS images
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_tags pt ON p.id = pt.product_id
LEFT JOIN tags t ON pt.tag_id = t.id
LEFT JOIN product_images pi ON p.id = pi.product_id
WHERE pc.name = $1
GROUP BY p.id
`;
const result = await qry(query, [categoryName]);
res.json(result.rows);
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,52 +0,0 @@
const express = require('express');
const router = express.Router();
const SystemSettings = require('../models/SystemSettings');
module.exports = (pool, query) => {
/**
* Get public branding settings
* GET /api/settings/branding
*/
router.get('/branding', async (req, res, next) => {
try {
// Get all settings with 'branding' category
const settings = await SystemSettings.getSettingsByCategory(pool, query, 'branding');
// Convert array of settings to an object for easier client-side use
const brandingSettings = {};
settings.forEach(setting => {
brandingSettings[setting.key] = setting.value;
});
res.json(brandingSettings);
} catch (error) {
next(error);
}
});
/**
* Get basic public settings (for meta tags, favicon, etc.)
* GET /api/settings/meta
*/
router.get('/meta', async (req, res, next) => {
try {
// Get relevant settings
const siteNameSetting = await SystemSettings.getSetting(pool, query, 'site_name');
const siteDescriptionSetting = await SystemSettings.getSetting(pool, query, 'site_description');
const faviconSetting = await SystemSettings.getSetting(pool, query, 'favicon_url');
// Create response object
const metaSettings = {
siteName: siteNameSetting?.value || 'Rocks, Bones & Sticks',
siteDescription: siteDescriptionSetting?.value || '',
faviconUrl: faviconSetting?.value || ''
};
res.json(metaSettings);
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,395 +0,0 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const SystemSettings = require('../models/SystemSettings');
const config = require('../config');
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
/**
* Get all settings
*/
router.get('/', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
const settings = await SystemSettings.getAllSettings(pool, query);
// Group settings by category
const groupedSettings = settings.reduce((acc, setting) => {
if (!acc[setting.category]) {
acc[setting.category] = [];
}
acc[setting.category].push(setting);
return acc;
}, {});
res.json(groupedSettings);
} catch (error) {
next(error);
}
});
/**
* Get settings by category
*/
router.get('/category/:category', async (req, res, next) => {
try {
const { category } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
const settings = await SystemSettings.getSettingsByCategory(pool, query, category);
res.json(settings);
} catch (error) {
next(error);
}
});
/**
* Get a specific setting
*/
router.get('/:key', async (req, res, next) => {
try {
const { key } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
const setting = await SystemSettings.getSetting(pool, query, key);
if (!setting) {
return res.status(404).json({
error: true,
message: `Setting with key "${key}" not found`
});
}
res.json(setting);
} catch (error) {
next(error);
}
});
/**
* Update a single setting
*/
router.put('/:key', async (req, res, next) => {
try {
const { key } = req.params;
const { value, category } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
if (value === undefined || !category) {
return res.status(400).json({
error: true,
message: 'Value and category are required'
});
}
const setting = await SystemSettings.getSetting(pool, query, key)
if(setting?.super_req && !req.user.is_super_admin){
return res.status(400).json({
error: true,
message: `Super Admin access required to modify ${key}`
});
}
const updatedSetting = await SystemSettings.updateSetting(pool, query, key, value, category);
// Update config in memory
updateConfigInMemory(updatedSetting);
// Update environment file
await updateEnvironmentFile();
res.json({
message: 'Setting updated successfully',
setting: updatedSetting
});
} catch (error) {
next(error);
}
});
/**
* Update multiple settings at once
*/
router.post('/batch', async (req, res, next) => {
try {
const { settings } = req.body;
console.log(req.user)
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
if (!Array.isArray(settings) || settings.length === 0) {
return res.status(400).json({
error: true,
message: 'Settings array is required'
});
}
const categorySettings = await SystemSettings.getSettingsByCategory(pool, query, settings[0]?.category)
// Validate all settings have required fields
for (const setting of settings) {
if (!setting.key || setting.value === undefined || !setting.category) {
return res.status(400).json({
error: true,
message: 'Each setting must have key, value, and category fields'
});
}
if(categorySettings.find(item => item.key === setting.key)?.super_req && !req.user.is_super_admin){
return res.status(400).json({
error: true,
message: `Super Admin access required to modify ${setting.key}`
});
}
}
const updatedSettings = await SystemSettings.updateSettings(pool, query, settings);
// Update config in memory for each setting
updatedSettings.forEach(setting => {
updateConfigInMemory(setting);
});
// Update environment file
await updateEnvironmentFile();
res.json({
message: 'Settings updated successfully',
settings: updatedSettings
});
} catch (error) {
next(error);
}
});
/**
* Delete a setting
*/
router.delete('/:key', async (req, res, next) => {
try {
const { key } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
await SystemSettings.deleteSetting(pool, query, key);
// Update environment file
await updateEnvironmentFile();
res.json({
message: `Setting "${key}" deleted successfully`
});
} catch (error) {
next(error);
}
});
/**
* Helper function to update config in memory
*/
function updateConfigInMemory(setting) {
const { key, value, category } = setting;
// Map database settings to config structure
if (category === 'server') {
if (key === 'port') config.port = parseInt(value, 10);
if (key === 'node_env') config.nodeEnv = value;
if (key === 'environment') config.environment = value;
} else if (category === 'database') {
if (key === 'db_host') config.db.host = value;
if (key === 'db_user') config.db.user = value;
if (key === 'site_read_host') config.db.readHost = value;
if (key === 'db_password') config.db.password = value;
if (key === 'db_name') config.db.database = value;
if (key === 'db_port') config.db.port = parseInt(value, 10);
if (key === 'site_db_max_connections') config.db.maxConnections = parseInt(value, 10);
} else if (category === 'email') {
if (key === 'smtp_host') config.email.host = value;
if (key === 'smtp_port') config.email.port = parseInt(value, 10);
if (key === 'smtp_user') config.email.user = value;
if (key === 'smtp_password') config.email.pass = value;
if (key === 'smtp_from_email') config.email.reply = value;
} else if (category === 'payment') {
if (key === 'stripe_enabled') config.payment.stripeEnabled = value === 'true';
if (key === 'stripe_public_key') config.payment.stripePublicKey = value;
if (key === 'stripe_secret_key') config.payment.stripeSecretKey = value;
if (key === 'stripe_webhook_secret') config.payment.stripeWebhookSecret = value;
} else if (category === 'shipping') {
if (key === 'shipping_enabled') config.shipping.enabled = value === 'true';
if (key === 'easypost_enabled') config.shipping.easypostEnabled = value === 'true';
if (key === 'easypost_api_key') config.shipping.easypostApiKey = value;
if (key === 'shipping_flat_rate') config.shipping.flatRate = parseFloat(value);
if (key === 'shipping_free_threshold') config.shipping.freeThreshold = parseFloat(value);
if (key === 'shipping_origin_street') config.shipping.originAddress.street = value;
if (key === 'shipping_origin_city') config.shipping.originAddress.city = value;
if (key === 'shipping_origin_state') config.shipping.originAddress.state = value;
if (key === 'shipping_origin_zip') config.shipping.originAddress.zip = value;
if (key === 'shipping_origin_country') config.shipping.originAddress.country = value;
if (key === 'shipping_default_package_length') config.shipping.defaultPackage.length = parseFloat(value);
if (key === 'shipping_default_package_width') config.shipping.defaultPackage.width = parseFloat(value);
if (key === 'shipping_default_package_height') config.shipping.defaultPackage.height = parseFloat(value);
if (key === 'shipping_default_package_unit') config.shipping.defaultPackage.unit = value;
if (key === 'shipping_default_weight_unit') config.shipping.defaultPackage.weightUnit = value;
if (key === 'shipping_carriers_allowed') config.shipping.carriersAllowed = value.split(',');
} else if (category === 'site') {
if (key === 'site_domain') config.site.domain = value;
if (key === 'site_api_domain') config.site.apiDomain = value;
if (key === 'site_analytics_api_key') config.site.analyticApiKey = value;
if (key === 'site_environment') config.environment = value;
if (key === 'site_deployment') config.site.deployment = value;
if (key === 'site_redis_host') config.site.redisHost = value;
if (key === 'site_redis_tls') config.site.redisTLS = value;
if (key === 'site_aws_region') config.site.awsRegion = value;
if (key === 'site_aws_s3_bucket') config.site.awsS3Bucket = value;
if (key === 'site_cdn_domain') config.site.cdnDomain = value;
if (key === 'site_aws_queue_url') config.site.awsQueueUrl = value;
if (key === 'site_session_secret') config.site.sessionSecret = value;
if (key === 'site_redis_port') config.site.redisPort = value;
if (key === 'site_redis_password') config.site.redisPassword = value;
}
}
/**
* Helper function to update environment file
*/
async function updateEnvironmentFile() {
try {
// Get all settings from database
const allSettings = await SystemSettings.getAllSettings(pool, query);
// First update the config with the database settings
config.updateFromDatabase(allSettings);
// Build environment variables string
let envContent = '';
// Server configuration
envContent += '# Server configuration\n';
envContent += `PORT=${config.port}\n`;
envContent += `NODE_ENV=${config.nodeEnv}\n`;
envContent += `ENVIRONMENT=${config.environment}\n`;
envContent += `DEPLOYMENT_MODE=${config.site.deployment}\n`;
envContent += `REDIS_HOST=${config.site.redisHost}\n`;
envContent += `REDIS_TLS=${config.site.redisTLS}\n`;
envContent += `REDIS_PORT=${config.site.redisPort}\n`;
envContent += `REDIS_PASSWORD=${config.site.redisPassword}\n`;
envContent += `AWS_REGION=${config.site.awsRegion}\n`;
envContent += `S3_BUCKET=${config.site.awsS3Bucket}\n`;
envContent += `CDN_DOMAIN=${config.site.cdnDomain}\n`;
envContent += `SQS_QUEUE_URL=${config.site.awsQueueUrl}\n`;
envContent += `SESSION_SECRET=${config.site.sessionSecret}\n\n`;
// Database configuration
envContent += '# Database configuration\n';
envContent += `DB_HOST=${config.db.host}\n`;
envContent += `DB_READ_HOST=${config.db.readHost}\n`;
envContent += `DB_USER=${config.db.user}\n`;
envContent += `DB_PASSWORD=${config.db.password}\n`;
envContent += `DB_NAME=${config.db.database}\n`;
envContent += `DB_PORT=${config.db.port}\n`;
envContent += `DB_MAX_CONNECTIONS=${config.db.maxConnections}\n`;
envContent += `POSTGRES_USER=${config.db.user}\n`;
envContent += `POSTGRES_PASSWORD=${config.db.password}\n`;
envContent += `POSTGRES_DB=${config.db.database}\n\n`;
// Email configuration
envContent += '# Email configuration\n';
envContent += `EMAIL_HOST=${config.email.host}\n`;
envContent += `EMAIL_PORT=${config.email.port}\n`;
envContent += `EMAIL_USER=${config.email.user}\n`;
envContent += `EMAIL_PASS=${config.email.pass}\n`;
envContent += `EMAIL_REPLY=${config.email.reply}\n\n`;
// Payment configuration
envContent += '# Payment configuration\n';
envContent += `STRIPE_ENABLED=${config.payment.stripeEnabled}\n`;
envContent += `STRIPE_PUBLIC_KEY=${config.payment.stripePublicKey}\n`;
envContent += `STRIPE_SECRET_KEY=${config.payment.stripeSecretKey}\n`;
envContent += `STRIPE_WEBHOOK_SECRET=${config.payment.stripeWebhookSecret}\n\n`;
// Shipping configuration
envContent += '# Shipping configuration\n';
envContent += `SHIPPING_ENABLED=${config.shipping.enabled}\n`;
envContent += `EASYPOST_ENABLED=${config.shipping.easypostEnabled}\n`;
envContent += `EASYPOST_API_KEY=${config.shipping.easypostApiKey}\n`;
envContent += `SHIPPING_FLAT_RATE=${config.shipping.flatRate}\n`;
envContent += `SHIPPING_FREE_THRESHOLD=${config.shipping.freeThreshold}\n`;
envContent += `SHIPPING_ORIGIN_STREET=${config.shipping.originAddress.street}\n`;
envContent += `SHIPPING_ORIGIN_CITY=${config.shipping.originAddress.city}\n`;
envContent += `SHIPPING_ORIGIN_STATE=${config.shipping.originAddress.state}\n`;
envContent += `SHIPPING_ORIGIN_ZIP=${config.shipping.originAddress.zip}\n`;
envContent += `SHIPPING_ORIGIN_COUNTRY=${config.shipping.originAddress.country}\n`;
envContent += `SHIPPING_DEFAULT_PACKAGE_LENGTH=${config.shipping.defaultPackage.length}\n`;
envContent += `SHIPPING_DEFAULT_PACKAGE_WIDTH=${config.shipping.defaultPackage.width}\n`;
envContent += `SHIPPING_DEFAULT_PACKAGE_HEIGHT=${config.shipping.defaultPackage.height}\n`;
envContent += `SHIPPING_DEFAULT_PACKAGE_UNIT=${config.shipping.defaultPackage.unit}\n`;
envContent += `SHIPPING_DEFAULT_WEIGHT_UNIT=${config.shipping.defaultPackage.weightUnit}\n`;
envContent += `SHIPPING_CARRIERS_ALLOWED=${config.shipping.carriersAllowed.join(',')}\n\n`;
// Site configuration
envContent += '# Site configuration\n';
if (config.environment === 'prod') {
// For production, use the actual domain values
envContent += `APP_PROD_URL=${config.site.domain}\n`;
envContent += `API_PROD_URL=${config.site.apiDomain}\n`;
} else {
// For beta/development, still include these but they won't be used
envContent += `APP_PROD_URL=${config.site.domain === 'localhost:3000' ? '' : config.site.domain}\n`;
envContent += `API_PROD_URL=${config.site.apiDomain === 'localhost:4000' ? '' : config.site.apiDomain}\n`;
}
envContent += `SITE_ANALYTIC_API=${config.site.analyticApiKey}\n`;
// Write to .env file
const envPath = path.join(__dirname, '../../.env');
await fs.writeFile(envPath, envContent);
console.log('Environment file updated successfully');
return true;
} catch (error) {
console.error('Error updating environment file:', error);
throw error;
}
}
return router;
};

View file

@ -1,179 +0,0 @@
const express = require('express');
const router = express.Router();
const shippingService = require('../services/shippingService.js');
const config = require('../config');
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
/**
* Get shipping rates
* POST /api/shipping/rates
*
* Request Body:
* {
* address: {
* name: string,
* street: string,
* city: string,
* state: string,
* zip: string,
* country: string,
* email: string
* },
* parcel: {
* length: number,
* width: number,
* height: number,
* weight: number,
* order_total: number
* },
* items: [{ id, quantity, weight_grams }] // Optional cart items for weight calculation
* }
*/
router.post('/rates', async (req, res, next) => {
try {
const { address, parcel, items } = req.body;
// Shipping must be enabled
if (!config.shipping.enabled) {
return res.status(400).json({
error: true,
message: 'Shipping is currently disabled'
});
}
// Validate required fields
if (!address || !parcel) {
return res.status(400).json({
error: true,
message: 'Address and parcel information are required'
});
}
// If address is a string, parse it
const parsedAddress = typeof address === 'string'
? shippingService.parseAddressString(address)
: address;
// Calculate total weight if items are provided
if (items && items.length > 0) {
parcel.weight = shippingService.calculateTotalWeight(items);
}
// Get shipping rates
const rates = await shippingService.getShippingRates(
null, // Use default from config
parsedAddress,
{
...parcel,
order_total: parcel.order_total || 0
}
);
res.json({
success: true,
rates
});
} catch (error) {
console.error('Error getting shipping rates:', error);
next(error);
}
});
/**
* Validate shipping address
* POST /api/shipping/validate-address
*
* Request Body:
* {
* address: {
* name: string,
* street: string,
* city: string,
* state: string,
* zip: string,
* country: string,
* email: string
* }
* }
*/
router.post('/validate-address', async (req, res, next) => {
try {
const { address } = req.body;
// Shipping must be enabled
if (!config.shipping.enabled) {
return res.status(400).json({
error: true,
message: 'Shipping is currently disabled'
});
}
// Validate required fields
if (!address) {
return res.status(400).json({
error: true,
message: 'Address information is required'
});
}
// If EasyPost is not enabled, just perform basic validation
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
const isValid = validateAddressFormat(address);
return res.json({
success: true,
valid: isValid,
original_address: address,
verified_address: address
});
}
// If address is a string, parse it
const parsedAddress = typeof address === 'string'
? shippingService.parseAddressString(address)
: address;
// TODO: Implement EasyPost address verification
// This would require making a call to EasyPost's API
// For now, we'll return the original address
res.json({
success: true,
valid: true,
original_address: parsedAddress,
verified_address: parsedAddress
});
} catch (error) {
console.error('Error validating address:', error);
next(error);
}
});
return router;
};
/**
* Basic address format validation
* @param {Object} address - Address to validate
* @returns {boolean} Whether the address has valid format
*/
function validateAddressFormat(address) {
// Check required fields
if (!address.street || !address.city || !address.zip || !address.country) {
return false;
}
// Check zip code format - just basic validation
if (address.country === 'US' && !/^\d{5}(-\d{4})?$/.test(address.zip)) {
return false;
}
if (address.country === 'CA' && !/^[A-Za-z]\d[A-Za-z] \d[A-Za-z]\d$/.test(address.zip)) {
return false;
}
return true;
}

View file

@ -1,259 +0,0 @@
const express = require('express');
const router = express.Router();
const stripe = require('stripe');
const config = require('../config');
const emailService = require('../services/emailService');
module.exports = (pool, query, authMiddleware) => {
// Webhook to handle events from Stripe
// before authmiddleware since stripe
// and json processing to prevent express from processing buffer as json obj
router.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
// This needs to be called with raw body data
const payload = req.body;
const sig = req.headers['stripe-signature'];
let event;
try {
// Verify the webhook signature
const webhookSecret = config.payment?.stripeWebhookSecret;
if (!webhookSecret) {
throw new Error('Stripe webhook secret is not configured');
}
event = stripeClient.webhooks.constructEvent(payload, sig, webhookSecret);
// Handle the event
switch (event.type) {
case 'checkout.session.completed' || "payment_intent.succeeded":
const session = event.data.object;
// Check if payment was successful
if (session.payment_status === 'paid') {
// Get metadata
const { order_id, user_id } = session.metadata;
if (order_id) {
// Update order status in database
await query(
'UPDATE orders SET status = $1, payment_completed = true, payment_id = $2 WHERE id = $3',
['processing', session.id, order_id]
);
console.log(`Payment completed for order ${order_id}`);
// Get order details for email confirmation
const orderResult = await query(`
SELECT o.*,
u.email, u.first_name, u.last_name,
o.shipping_address
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = $1
`, [order_id]);
if (orderResult.rows.length > 0) {
const order = orderResult.rows[0];
// Get order items with product details
const itemsResult = await query(`
SELECT
oi.id,
oi.quantity,
oi.price_at_purchase,
p.name as product_name
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = $1
`, [order_id]);
const itemsHtml = itemsResult.rows.map(item => `
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
</tr>
`).join('');
let shippingAddress = 'No shipping address provided';
if (order.shipping_address) {
shippingAddress = order.shipping_address;
}
await emailService.sendOrderConfirmation({
to: order.email,
first_name: order.first_name || 'Customer',
order_id: order.id.substring(0, 8), // first 8 characters of UUID for cleaner display
order_date: new Date(order.created_at).toLocaleDateString(),
order_total: `$${parseFloat(order.total_amount).toFixed(2)}`,
shipping_address: shippingAddress,
items_html: itemsHtml
});
console.log(`Order confirmation email sent to ${order.email}`);
}
}
}
break;
case 'payment_intent.payment_failed':
const paymentIntent = event.data.object;
console.log(`Payment failed: ${paymentIntent.last_payment_error?.message}`);
// Handle failed payment
if (paymentIntent.metadata?.order_id) {
await query(
'UPDATE orders SET status = $1, payment_notes = $2 WHERE id = $3',
['payment_failed', 'Payment attempt failed', paymentIntent.metadata.order_id]
);
}
break;
default:
console.log(`Unhandled event type ${event.type}`);
}
// Return a 200 success response
res.status(200).send();
} catch (err) {
console.error(`Webhook Error: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
});
router.use(express.json());
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Initialize Stripe with the secret key from config
const stripeClient = stripe(config.payment?.stripeSecretKey);
// Create checkout session
router.post('/create-checkout-session', async (req, res, next) => {
try {
const { cartItems, shippingAddress, userId, orderId, shippingDetails } = req.body;
if (!cartItems || cartItems.length === 0) {
return res.status(400).json({
error: true,
message: 'Cart items are required'
});
}
// Verify user has access to this cart
if (req.user.id !== userId) {
return res.status(403).json({
error: true,
message: 'You can only checkout your own cart'
});
}
// Format line items for Stripe
const lineItems = cartItems.map(item => {
return {
price_data: {
currency: 'usd',
product_data: {
name: item.name,
description: item.description ? item.description.substring(0, 500) : undefined,
images: item.primary_image ? [
`${config.site.protocol}://${config.site.apiDomain}${item.primary_image.path}`
] : undefined,
},
unit_amount: Math.round(parseFloat(item.price) * 100), // Convert to cents
},
quantity: item.quantity,
};
});
// Create metadata with shipping info and order reference
const metadata = {
order_id: orderId,
user_id: userId,
shipping_address: JSON.stringify(shippingAddress),
};
// Add tracking information to metadata if available
if (shippingDetails && shippingDetails.tracking_code) {
metadata.tracking_code = shippingDetails.tracking_code;
metadata.shipping_carrier = shippingDetails.shipping_method || 'Standard Shipping';
}
// Create Stripe checkout session
const session = await stripeClient.checkout.sessions.create({
line_items: lineItems,
mode: 'payment',
success_url: `${config.site.protocol}://${config.site.domain}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.site.protocol}://${config.site.domain}/checkout/cancel`,
metadata: metadata,
shipping_options: [
{
shipping_rate_data: {
type: 'fixed_amount',
fixed_amount: {
amount: shippingDetails && shippingDetails.shipping_cost ?
Math.round(parseFloat(shippingDetails.shipping_cost) * 100) : 0,
currency: 'usd',
},
display_name: shippingDetails && shippingDetails.shipping_method ?
shippingDetails.shipping_method : 'Standard Shipping',
delivery_estimate: {
minimum: {
unit: 'business_day',
value: 2,
},
maximum: {
unit: 'business_day',
value: 7,
},
},
},
},
],
});
// Return the session ID to the client
res.json({
success: true,
sessionId: session.id,
url: session.url
});
} catch (error) {
console.error('Stripe checkout error:', error);
next(error);
}
});
// Verify payment status
router.get('/session-status/:sessionId', async (req, res, next) => {
try {
const { sessionId } = req.params;
// Retrieve the session from Stripe
const session = await stripeClient.checkout.sessions.retrieve(sessionId);
res.json({
success: true,
status: session.payment_status,
metadata: session.metadata
});
} catch (error) {
console.error('Error checking session status:', error);
next(error);
}
});
router.get('/config', async (req, res, next) => {
try {
res.json({
stripePublicKey: config.payment?.stripePublicKey || '',
stripeEnabled: config.payment?.stripeEnabled || false
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,407 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const emailService = require('../services/emailService');
module.exports = (pool, query, authMiddleware) => {
/**
* Public route to handle subscription
* POST /api/subscribers/subscribe
*/
router.post('/subscribe', async (req, res, next) => {
try {
const { email, firstName, lastName, listId } = req.body;
// Validate required fields
if (!email || !listId) {
return res.status(400).json({
error: true,
message: 'Email and list ID are required'
});
}
// Check if list exists
const listCheck = await query(
'SELECT id FROM mailing_lists WHERE id = $1',
[listId]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if subscriber already exists
let subscriberId;
const subscriberCheck = await client.query(
'SELECT id, status FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length > 0) {
// Update existing subscriber
subscriberId = subscriberCheck.rows[0].id;
const currentStatus = subscriberCheck.rows[0].status;
// Only update if not already unsubscribed or complained
if (currentStatus !== 'unsubscribed' && currentStatus !== 'complained') {
await client.query(
`UPDATE subscribers
SET first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
status = 'active',
updated_at = NOW()
WHERE id = $3`,
[firstName, lastName, subscriberId]
);
} else {
await client.query('ROLLBACK');
return res.status(400).json({
error: true,
message: 'This email has previously unsubscribed and cannot be resubscribed without consent'
});
}
} else {
// Create new subscriber
subscriberId = uuidv4();
await client.query(
`INSERT INTO subscribers (id, email, first_name, last_name)
VALUES ($1, $2, $3, $4)`,
[subscriberId, email, firstName, lastName]
);
}
// Check if subscriber is already on this list
const listSubscriberCheck = await client.query(
'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2',
[listId, subscriberId]
);
if (listSubscriberCheck.rows.length === 0) {
// Add subscriber to list
await client.query(
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id)
VALUES ($1, $2)`,
[listId, subscriberId]
);
}
await client.query('COMMIT');
// Generate a confirmation token for double opt-in
const confirmationToken = uuidv4();
// Store the confirmation token
await query(
`INSERT INTO subscription_confirmations (subscriber_id, token, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
[subscriberId, confirmationToken]
);
// Send confirmation email
try {
await emailService.sendSubscriptionConfirmation({
to: email,
firstName: firstName || '',
confirmationLink: `${process.env.SITE_URL}/confirm-subscription?token=${confirmationToken}`
});
} catch (emailError) {
console.error('Failed to send confirmation email:', emailError);
}
res.status(200).json({
success: true,
message: 'Subscription request received. Please check your email to confirm your subscription.'
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Confirm subscription (double opt-in)
* GET /api/subscribers/confirm
*/
router.get('/confirm', async (req, res, next) => {
try {
const { token } = req.query;
if (!token) {
return res.status(400).json({
error: true,
message: 'Confirmation token is required'
});
}
// Check if token exists and is valid
const confirmationQuery = `
SELECT
sc.subscriber_id,
s.email,
s.first_name
FROM subscription_confirmations sc
JOIN subscribers s ON sc.subscriber_id = s.id
WHERE sc.token = $1 AND sc.expires_at > NOW() AND sc.confirmed_at IS NULL
`;
const confirmationResult = await query(confirmationQuery, [token]);
if (confirmationResult.rows.length === 0) {
return res.status(400).json({
error: true,
message: 'Invalid or expired confirmation token'
});
}
const { subscriber_id, email, first_name } = confirmationResult.rows[0];
// Mark subscription as confirmed
await query(
`UPDATE subscription_confirmations
SET confirmed_at = NOW()
WHERE token = $1`,
[token]
);
// Ensure subscriber is marked as active
await query(
`UPDATE subscribers
SET status = 'active', updated_at = NOW()
WHERE id = $1`,
[subscriber_id]
);
// Send welcome email
try {
await emailService.sendWelcomeEmail({
to: email,
firstName: first_name || ''
});
} catch (emailError) {
console.error('Failed to send welcome email:', emailError);
}
res.status(200).json({
success: true,
message: 'Your subscription has been confirmed. Thank you!'
});
} catch (error) {
next(error);
}
});
/**
* Handle unsubscribe request
* GET /api/subscribers/unsubscribe
*/
router.get('/unsubscribe', async (req, res, next) => {
try {
const { email, token, listId } = req.query;
if (!email && !token) {
return res.status(400).json({
error: true,
message: 'Email or token is required'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
let subscriberId;
if (token) {
// Verify the unsubscribe token
const tokenCheck = await client.query(
'SELECT subscriber_id FROM unsubscribe_tokens WHERE token = $1 AND expires_at > NOW()',
[token]
);
if (tokenCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(400).json({
error: true,
message: 'Invalid or expired unsubscribe token'
});
}
subscriberId = tokenCheck.rows[0].subscriber_id;
} else {
// Look up subscriber by email
const subscriberCheck = await client.query(
'SELECT id FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({
error: true,
message: 'Email not found in our database'
});
}
subscriberId = subscriberCheck.rows[0].id;
}
// Update subscriber status
await client.query(
`UPDATE subscribers
SET status = 'unsubscribed', updated_at = NOW()
WHERE id = $1`,
[subscriberId]
);
// If list ID is provided, only remove from that list
if (listId) {
await client.query(
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1 AND list_id = $2',
[subscriberId, listId]
);
}
// Otherwise remove from all lists
else {
await client.query(
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
[subscriberId]
);
}
// Record unsubscribe activity
await client.query(
`INSERT INTO subscriber_activity (subscriber_id, type, details)
VALUES ($1, 'unsubscribe', $2)`,
[subscriberId, listId ? `Unsubscribed from list ${listId}` : 'Unsubscribed from all lists']
);
await client.query('COMMIT');
res.status(200).json({
success: true,
message: 'You have been successfully unsubscribed'
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Public route to handle bounce and complaint notifications
* POST /api/subscribers/webhook
*/
router.post('/webhook', async (req, res, next) => {
try {
const { type, email, message, campaignId, reason, timestamp } = req.body;
// Validate required fields
if (!type || !email) {
return res.status(400).json({
error: true,
message: 'Type and email are required'
});
}
// Verify webhook signature
// In production, implement proper verification of webhook authenticity
// Find subscriber by email
const subscriberCheck = await query(
'SELECT id FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Subscriber not found'
});
}
const subscriberId = subscriberCheck.rows[0].id;
// Handle event based on type
switch (type) {
case 'bounce':
// Update subscriber status
await query(
`UPDATE subscribers
SET status = 'bounced', updated_at = NOW()
WHERE id = $1`,
[subscriberId]
);
// Record bounce activity
await query(
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details)
VALUES ($1, $2, 'bounce', $3, $4)`,
[subscriberId, campaignId, timestamp || new Date(), reason || message || 'Email bounced']
);
break;
case 'complaint':
// Update subscriber status
await query(
`UPDATE subscribers
SET status = 'complained', updated_at = NOW()
WHERE id = $1`,
[subscriberId]
);
// Remove from all lists
await query(
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
[subscriberId]
);
// Record complaint activity
await query(
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details)
VALUES ($1, $2, 'complaint', $3, $4)`,
[subscriberId, campaignId, timestamp || new Date(), reason || message || 'Spam complaint received']
);
break;
default:
return res.status(400).json({
error: true,
message: 'Unsupported event type'
});
}
res.json({
success: true,
message: `Processed ${type} for ${email}`
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,579 +0,0 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const emailService = require('../services/emailService');
module.exports = (pool, query, authMiddleware) => {
/**
* Public route to handle subscription
* POST /api/subscribers/subscribe
*/
router.post('/subscribe', async (req, res, next) => {
try {
const { email, firstName, lastName, listId } = req.body;
// Validate required fields
if (!email || !listId) {
return res.status(400).json({
error: true,
message: 'Email and list ID are required'
});
}
// Check if list exists
const listCheck = await query(
'SELECT id FROM mailing_lists WHERE id = $1',
[listId]
);
if (listCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Mailing list not found'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if subscriber already exists
let subscriberId;
const subscriberCheck = await client.query(
'SELECT id, status FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length > 0) {
// Update existing subscriber
subscriberId = subscriberCheck.rows[0].id;
const currentStatus = subscriberCheck.rows[0].status;
// Only update if not already unsubscribed or complained
if (currentStatus !== 'unsubscribed' && currentStatus !== 'complained') {
await client.query(
`UPDATE subscribers
SET first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
status = 'active',
updated_at = NOW()
WHERE id = $3`,
[firstName, lastName, subscriberId]
);
} else {
await client.query('ROLLBACK');
return res.status(400).json({
error: true,
message: 'This email has previously unsubscribed and cannot be resubscribed without consent'
});
}
} else {
// Create new subscriber
subscriberId = uuidv4();
await client.query(
`INSERT INTO subscribers (id, email, first_name, last_name)
VALUES ($1, $2, $3, $4)`,
[subscriberId, email, firstName, lastName]
);
}
// Check if subscriber is already on this list
const listSubscriberCheck = await client.query(
'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2',
[listId, subscriberId]
);
if (listSubscriberCheck.rows.length === 0) {
// Add subscriber to list
await client.query(
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id)
VALUES ($1, $2)`,
[listId, subscriberId]
);
}
await client.query('COMMIT');
// Generate a confirmation token for double opt-in
const confirmationToken = uuidv4();
// Store the confirmation token
await query(
`INSERT INTO subscription_confirmations (subscriber_id, token, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
[subscriberId, confirmationToken]
);
// Send confirmation email
try {
await emailService.sendSubscriptionConfirmation({
to: email,
firstName: firstName || '',
confirmationLink: `${process.env.SITE_URL}/confirm-subscription?token=${confirmationToken}`
});
} catch (emailError) {
console.error('Failed to send confirmation email:', emailError);
}
res.status(200).json({
success: true,
message: 'Subscription request received. Please check your email to confirm your subscription.'
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Confirm subscription (double opt-in)
* GET /api/subscribers/confirm
*/
router.get('/confirm', async (req, res, next) => {
try {
const { token } = req.query;
if (!token) {
return res.status(400).json({
error: true,
message: 'Confirmation token is required'
});
}
// Check if token exists and is valid
const confirmationQuery = `
SELECT
sc.subscriber_id,
s.email,
s.first_name
FROM subscription_confirmations sc
JOIN subscribers s ON sc.subscriber_id = s.id
WHERE sc.token = $1 AND sc.expires_at > NOW() AND sc.confirmed_at IS NULL
`;
const confirmationResult = await query(confirmationQuery, [token]);
if (confirmationResult.rows.length === 0) {
return res.status(400).json({
error: true,
message: 'Invalid or expired confirmation token'
});
}
const { subscriber_id, email, first_name } = confirmationResult.rows[0];
// Mark subscription as confirmed
await query(
`UPDATE subscription_confirmations
SET confirmed_at = NOW()
WHERE token = $1`,
[token]
);
// Ensure subscriber is marked as active
await query(
`UPDATE subscribers
SET status = 'active', updated_at = NOW()
WHERE id = $1`,
[subscriber_id]
);
// Send welcome email
try {
await emailService.sendWelcomeEmail({
to: email,
firstName: first_name || ''
});
} catch (emailError) {
console.error('Failed to send welcome email:', emailError);
}
res.status(200).json({
success: true,
message: 'Your subscription has been confirmed. Thank you!'
});
} catch (error) {
next(error);
}
});
/**
* Handle unsubscribe request
* GET /api/subscribers/unsubscribe
*/
router.get('/unsubscribe', async (req, res, next) => {
try {
const { email, token, listId } = req.query;
if (!email && !token) {
return res.status(400).json({
error: true,
message: 'Email or token is required'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
let subscriberId;
if (token) {
// Verify the unsubscribe token
const tokenCheck = await client.query(
'SELECT subscriber_id FROM unsubscribe_tokens WHERE token = $1 AND expires_at > NOW()',
[token]
);
if (tokenCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(400).json({
error: true,
message: 'Invalid or expired unsubscribe token'
});
}
subscriberId = tokenCheck.rows[0].subscriber_id;
} else {
// Look up subscriber by email
const subscriberCheck = await client.query(
'SELECT id FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({
error: true,
message: 'Email not found in our database'
});
}
subscriberId = subscriberCheck.rows[0].id;
}
// Update subscriber status
await client.query(
`UPDATE subscribers
SET status = 'unsubscribed', updated_at = NOW()
WHERE id = $1`,
[subscriberId]
);
// If list ID is provided, only remove from that list
if (listId) {
await client.query(
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1 AND list_id = $2',
[subscriberId, listId]
);
}
// Otherwise remove from all lists
else {
await client.query(
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
[subscriberId]
);
}
// Record unsubscribe activity
await client.query(
`INSERT INTO subscriber_activity (subscriber_id, type, details)
VALUES ($1, 'unsubscribe', $2)`,
[subscriberId, listId ? `Unsubscribed from list ${listId}` : 'Unsubscribed from all lists']
);
await client.query('COMMIT');
res.status(200).json({
success: true,
message: 'You have been successfully unsubscribed'
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Public route to handle bounce and complaint notifications
* POST /api/subscribers/webhook
*/
router.post('/webhook', async (req, res, next) => {
try {
const { type, email, message, campaignId, reason, timestamp } = req.body;
// Validate required fields
if (!type || !email) {
return res.status(400).json({
error: true,
message: 'Type and email are required'
});
}
// Verify webhook signature
// In production, implement proper verification of webhook authenticity
// Find subscriber by email
const subscriberCheck = await query(
'SELECT id FROM subscribers WHERE email = $1',
[email]
);
if (subscriberCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Subscriber not found'
});
}
const subscriberId = subscriberCheck.rows[0].id;
// Handle event based on type
switch (type) {
case 'bounce':
// Update subscriber status
await query(
`UPDATE subscribers
SET status = 'bounced', updated_at = NOW()
WHERE id = $1`,
[subscriberId]
);
// Record bounce activity
await query(
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details)
VALUES ($1, $2, 'bounce', $3, $4)`,
[subscriberId, campaignId, timestamp || new Date(), reason || message || 'Email bounced']
);
break;
case 'complaint':
// Update subscriber status
await query(
`UPDATE subscribers
SET status = 'complained', updated_at = NOW()
WHERE id = $1`,
[subscriberId]
);
// Remove from all lists
await query(
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
[subscriberId]
);
// Record complaint activity
await query(
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details)
VALUES ($1, $2, 'complaint', $3, $4)`,
[subscriberId, campaignId, timestamp || new Date(), reason || message || 'Spam complaint received']
);
break;
default:
return res.status(400).json({
error: true,
message: 'Unsupported event type'
});
}
res.json({
success: true,
message: `Processed ${type} for ${email}`
});
} catch (error) {
next(error);
}
});
router.use(authMiddleware);
/**
* Update a subscriber (admin)
* PUT /api/subscribers/:id
*/
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { email, firstName, lastName, status } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!email) {
return res.status(400).json({
error: true,
message: 'Email is required'
});
}
// Check if subscriber exists
const subscriberCheck = await query(
'SELECT id FROM subscribers WHERE id = $1',
[id]
);
if (subscriberCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Subscriber not found'
});
}
// Update subscriber
const result = await query(
`UPDATE subscribers
SET email = $1,
first_name = $2,
last_name = $3,
status = $4,
updated_at = NOW()
WHERE id = $5
RETURNING *`,
[email, firstName, lastName, status, id]
);
res.json(result.rows[0]);
} catch (error) {
next(error);
}
});
/**
* Delete a subscriber (admin)
* DELETE /api/subscribers/:id
*/
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if subscriber exists
const subscriberCheck = await query(
'SELECT id FROM subscribers WHERE id = $1',
[id]
);
if (subscriberCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Subscriber not found'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Remove from all mailing lists
await client.query(
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
[id]
);
// Delete subscriber
await client.query(
'DELETE FROM subscribers WHERE id = $1',
[id]
);
await client.query('COMMIT');
res.json({
success: true,
message: 'Subscriber deleted successfully'
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Get subscriber activity (admin)
* GET /api/subscribers/:id/activity
*/
router.get('/:id/activity', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if subscriber exists
const subscriberCheck = await query(
'SELECT id FROM subscribers WHERE id = $1',
[id]
);
if (subscriberCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Subscriber not found'
});
}
// Get subscriber activity
const activityQuery = `
SELECT
sa.id,
sa.campaign_id,
ec.name as campaign_name,
sa.type,
sa.timestamp,
sa.details
FROM subscriber_activity sa
LEFT JOIN email_campaigns ec ON sa.campaign_id = ec.id
WHERE sa.subscriber_id = $1
ORDER BY sa.timestamp DESC
`;
const activityResult = await query(activityQuery, [id]);
res.json(activityResult.rows);
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,248 +0,0 @@
const express = require('express');
const nodemailer = require('nodemailer');
const config = require('../config');
const router = express.Router();
const createTransporter = () => {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
};
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all users
router.get('/', async (req, res, next) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
const result = await query(`
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.is_admin,
u.is_disabled,
u.internal_notes,
u.created_at,
u.last_login,
CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin
FROM
users u
LEFT JOIN
superadmins s ON u.id = s.user_id
ORDER BY
u.last_login DESC NULLS LAST
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get single user
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
const result = await query(`
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.is_admin,
u.is_disabled,
u.internal_notes,
u.created_at,
u.last_login,
CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin
FROM
users u
LEFT JOIN
superadmins s ON u.id = s.user_id
WHERE
u.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'User not found'
});
}
res.json(result.rows[0]);
} catch (error) {
next(error);
}
});
// Update user (admin can update is_disabled, is_admin and internal_notes)
router.patch('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { is_disabled, internal_notes, is_admin } = req.body;
// Verify admin status from middleware
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
if (is_admin !== undefined && typeof is_admin !== 'boolean') {
return res.status(400).json({
error: true,
message: 'is_admin must be a boolean'
});
}
// 4. Check if user exists and get their details (including superadmin status)
const userCheck = await query(`
SELECT
u.*,
CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin
FROM
users u
LEFT JOIN
superadmins s ON u.id = s.user_id
WHERE
u.id = $1
`, [id]);
if (userCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'User not found'
});
}
const targetUser = userCheck.rows[0];
// - If target user is a superadmin, only allow self-editing
if (targetUser.is_super_admin && req.user.id !== targetUser.id) {
return res.status(403).json({
error: true,
message: 'Superadmin accounts can only be edited by themselves'
});
}
const result = await query(`
UPDATE users
SET
is_disabled = $1,
internal_notes = $2,
is_admin = CASE
WHEN EXISTS (SELECT 1 FROM superadmins WHERE user_id = $4) THEN TRUE
ELSE $3
END
WHERE id = $4
RETURNING
id,
email,
first_name,
last_name,
is_admin,
is_disabled,
internal_notes,
(SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END FROM superadmins WHERE user_id = $4) AS is_super_admin
`, [
is_disabled !== undefined ? is_disabled : targetUser.is_disabled,
internal_notes !== undefined ? internal_notes : targetUser.internal_notes,
is_admin !== undefined ? is_admin : targetUser.is_admin,
id
]);
res.json({
message: 'User updated successfully',
user: result.rows[0]
});
} catch (error) {
// Pass to error handler middleware
next(error);
}
});
// Send email to user
router.post('/send-email', async (req, res, next) => {
try {
const { to, name, subject, message } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!to || !subject || !message) {
return res.status(400).json({
error: true,
message: 'Email, subject, and message are required'
});
}
// Create email transporter (using the same transporter from auth.js)
const transporter = createTransporter();
// Send email
await transporter.sendMail({
from: config.email.reply,
to: to,
subject: subject,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Message from ${config.site.domain}</h2>
<p>Dear ${name},</p>
<div style="padding: 15px; background-color: #f7f7f7; border-radius: 5px;">
${message.replace(/\n/g, '<br>')}
</div>
<p style="margin-top: 20px; font-size: 12px; color: #666;">
This email was sent from the admin panel of ${config.site.domain}.
</p>
</div>
`
});
// Log the email sending (optional)
await query(
'INSERT INTO email_logs (recipient, subject, sent_by) VALUES ($1, $2, $3)',
[to, subject, req.user.id]
);
res.json({
success: true,
message: 'Email sent successfully'
});
} catch (error) {
console.error('Email sending error:', error);
next(error);
}
});
return router;
};

View file

@ -1,103 +0,0 @@
// Create a new file called userOrders.js in the routes directory
const express = require('express');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all orders for the authenticated user
router.get('/', async (req, res, next) => {
try {
const userId = req.user.id;
// Get orders with basic information
const result = await query(`
SELECT
o.id,
o.status,
o.total_amount,
o.shipping_address,
o.created_at,
o.updated_at,
o.payment_completed,
o.shipping_date,
COUNT(oi.id) AS item_count,
CASE WHEN o.shipping_info IS NOT NULL THEN true ELSE false END AS has_shipping_info,
(SELECT SUM(quantity) FROM order_items WHERE order_id = o.id) AS total_items
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = $1
GROUP BY o.id
ORDER BY o.created_at DESC
`, [userId]);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get a single order with details for the authenticated user
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.id;
// Get order with verification that it belongs to the current user
const orderResult = await query(`
SELECT o.*
FROM orders o
WHERE o.id = $1 AND o.user_id = $2
`, [id, userId]);
if (orderResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Order not found'
});
}
// Get order items with product details
const itemsResult = await query(`
SELECT
oi.id,
oi.product_id,
oi.quantity,
oi.price_at_purchase,
p.name as product_name,
p.description as product_description,
pc.name as product_category,
(
SELECT json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
)
FROM product_images pi
WHERE pi.product_id = p.id
) AS product_images
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE oi.order_id = $1
`, [id]);
// Combine order with items
const order = {
...orderResult.rows[0],
items: itemsResult.rows
};
res.json(order);
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -1,58 +0,0 @@
const Redis = require('ioredis');
const config = require('../config');
class CacheService {
constructor() {
this.enabled = config.site.deployment === 'cloud' && config.site.redisHost
this.client = null;
if (this.enabled) {
this.client = new Redis({
host: config.site.redisHost,
port: config.site.redisPort,
password: config.site.redisPassword,
tls: config.site.redisTLS ? {} : undefined,
});
this.client.on('error', (err) => {
console.error('Redis Client Error', err);
});
}
}
async get(key) {
if (!this.enabled) return null;
try {
const value = await this.client.get(key);
return value ? JSON.parse(value) : null;
} catch (error) {
console.error('Cache get error:', error);
return null;
}
}
async set(key, value, ttlSeconds = 300) {
if (!this.enabled) return;
try {
await this.client.setex(key, ttlSeconds, JSON.stringify(value));
} catch (error) {
console.error('Cache set error:', error);
}
}
async del(key) {
if (!this.enabled) return;
try {
await this.client.del(key);
} catch (error) {
console.error('Cache delete error:', error);
}
}
}
// Create a singleton instance
const cacheService = new CacheService();
module.exports = cacheService;

View file

@ -1,630 +0,0 @@
const nodemailer = require('nodemailer');
const config = require('../config');
const { query, pool } = require('../db');
const { v4: uuidv4 } = require('uuid');
/**
* Service for sending emails with templates
*/
const emailService = {
/**
* Create email transporter
* @returns {Object} Configured nodemailer transporter
*/
createTransporter() {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
},
defaultFrom: config.email.reply,
siteUrl: `${config.site.protocol}://${config.site.apiDomain}`,
/**
* Get a template by type, preferring the default one
* @param {string} type - Template type
* @returns {Promise<Object|null>} Template object or null if not found
*/
async getTemplateByType(type) {
try {
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find the default template for the specified type
let defaultTemplate = null;
let fallbackTemplate = null;
for (const setting of result.rows) {
try {
console.log(setting.value, typeof setting.value)
const templateData = JSON.parse(setting.value);
if (templateData.type === type) {
if (templateData.isDefault) {
defaultTemplate = {
id: setting.key,
...templateData
};
break; // Found the default template
} else if (!fallbackTemplate) {
// Keep a fallback template in case no default is found
fallbackTemplate = {
id: setting.key,
...templateData
};
}
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
// Return default template if found, otherwise return fallback or null
return defaultTemplate || fallbackTemplate || null;
} catch (error) {
console.error('Error getting template by type:', error);
return null;
}
},
/**
* Replace template variables with actual values
* @param {string} content - Template content
* @param {Object} variables - Variable values
* @returns {string} Processed content
*/
replaceVariables(content, variables) {
let processedContent = content;
if (variables) {
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
const regex = new RegExp(placeholder, 'g');
processedContent = processedContent.replace(regex, value || '');
}
}
return processedContent;
},
/**
* Send an email using a template
* @param {Object} options - Email options
* @param {string} options.to - Recipient email address
* @param {string} options.templateType - Template type
* @param {Object} options.variables - Template variables
* @param {string} [options.from] - Sender email (optional, defaults to config)
* @param {string} [options.subject] - Custom subject (optional, defaults to template subject)
* @param {string} [options.cc] - CC recipients (optional)
* @param {string} [options.bcc] - BCC recipients (optional)
* @returns {Promise<boolean>} Success status
*/
async sendTemplatedEmail(options) {
try {
const { to, templateType, variables, from, subject, cc, bcc } = options;
// Get template
const template = await this.getTemplateByType(templateType);
if (!template) {
throw new Error(`No template found for type: ${templateType}`);
}
// Replace variables in content and subject
const emailContent = this.replaceVariables(template.content, variables);
const emailSubject = subject || this.replaceVariables(template.subject, variables);
// Create transporter
const transporter = this.createTransporter();
// Send email
const result = await transporter.sendMail({
from: from || config.email.reply,
to,
cc,
bcc,
subject: emailSubject,
html: emailContent
});
console.log(`Email sent: ${result.messageId}`);
return true;
} catch (error) {
console.error('Error sending templated email:', error);
throw error;
}
},
/**
* Send an email
* @param {Object} options - Email options for nodemailer
* @returns {Promise} - Promise with the send result
*/
async sendEmail(options) {
try {
// Send email using nodemailer
const transporter = this.createTransporter();
const result = await transporter.sendMail({
from: options.from || this.defaultFrom,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text,
attachments: options.attachments,
headers: options.headers
});
return result;
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
},
/**
* Send a login code email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.code - Login verification code
* @param {string} options.loginLink - Direct login link
* @returns {Promise<boolean>} Success status
*/
async sendLoginCodeEmail(options) {
const { to, code, loginLink } = options;
return this.sendTemplatedEmail({
to,
templateType: 'login_code',
variables: {
code,
loginLink,
email: to
}
});
},
/**
* Send a shipping notification email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.first_name - Customer's first name
* @param {string} options.order_id - Order ID
* @param {string} options.tracking_number - Tracking number
* @param {string} options.carrier - Shipping carrier
* @param {string} options.tracking_link - Tracking link
* @param {string} options.shipped_date - Ship date
* @param {string} options.estimated_delivery - Estimated delivery
* @param {string} options.items_html - Order items HTML table
* @param {string} options.customer_message - Custom message
* @returns {Promise<boolean>} Success status
*/
async sendShippingNotification(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'shipping_notification',
variables: options
});
},
/**
* Send an order confirmation email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.first_name - Customer's first name
* @param {string} options.order_id - Order ID
* @param {string} options.order_date - Order date
* @param {string} options.order_total - Order total
* @param {string} options.shipping_address - Shipping address
* @param {string} options.items_html - Order items HTML table
* @returns {Promise<boolean>} Success status
*/
async sendOrderConfirmation(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'order_confirmation',
variables: options
});
},
/**
* Send a test email for campaign preview
* @param {Object} options - Email options
* @param {string} options.to - Recipient email
* @param {string} options.subject - Email subject
* @param {string} options.preheader - Email preheader
* @param {string} options.from - From address
* @param {string} options.content - HTML content
* @returns {Promise} - Promise with the send result
*/
async sendTestEmail({ to, subject, preheader, from, content }) {
try {
// Add preheader if provided
let htmlContent = content;
if (preheader) {
htmlContent = `
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
${preheader}
</div>
${content}
`;
}
// Add test label at top
htmlContent = `
<div style="background-color:#ffeb3b;padding:10px;margin-bottom:10px;text-align:center;font-family:sans-serif;">
<strong>TEST EMAIL</strong> - This is a test preview of your campaign.
</div>
${htmlContent}
`;
// Send the test email
return await this.sendEmail({
to,
subject,
html: htmlContent,
from: from || this.defaultFrom,
headers: {
'X-Test-Email': 'true'
}
});
} catch (error) {
console.error('Error sending test email:', error);
throw error;
}
},
/**
* Send a low stock alert email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.product_name - Product name
* @param {string} options.current_stock - Current stock level
* @param {string} options.threshold - Stock threshold
* @returns {Promise<boolean>} Success status
*/
async sendLowStockAlert(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'low_stock_alert',
variables: options
});
},
/**
* Send a welcome email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.first_name - User's first name
* @returns {Promise<boolean>} Success status
*/
async sendWelcomeEmail(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'welcome_email',
variables: {
first_name: options.first_name,
email: options.to
}
});
},
/**
* Send a subscription confirmation email
* @param {Object} options - Email options
* @param {string} options.to - Recipient email
* @param {string} options.firstName - Recipient's first name
* @param {string} options.confirmationLink - Confirmation link
* @returns {Promise} - Promise with the send result
*/
async sendSubscriptionConfirmation({ to, firstName, confirmationLink }) {
try {
// Get the email template
const template = await this.getEmailTemplate('subscription_confirmation');
if (!template) {
// Fallback to a basic template if none is found
return await this.sendEmail({
to,
subject: 'Please confirm your subscription',
html: `
<h1>Confirm Your Subscription</h1>
<p>Hello ${firstName || 'there'},</p>
<p>Thank you for subscribing to our mailing list. Please click the link below to confirm your subscription:</p>
<p><a href="${confirmationLink}">Confirm Subscription</a></p>
<p>If you did not request this subscription, you can ignore this email.</p>
`,
from: this.defaultFrom
});
}
// Replace placeholders in the template
let content = template.content;
let subject = template.subject;
content = content
.replace(/{{first_name}}/g, firstName || 'there')
.replace(/{{confirmation_link}}/g, confirmationLink);
subject = subject
.replace(/{{first_name}}/g, firstName || 'there');
// Send email
return await this.sendEmail({
to,
subject,
html: content,
from: template.from_name ? `${template.from_name} <${this.defaultFrom}>` : this.defaultFrom
});
} catch (error) {
console.error('Error sending subscription confirmation email:', error);
throw error;
}
},
/**
* Send an email to a campaign subscriber
* @param {Object} options - Email options
* @param {string} options.to - Recipient email
* @param {string} options.subject - Email subject
* @param {string} options.preheader - Email preheader
* @param {string} options.from - From address
* @param {string} options.content - HTML content
* @param {string} options.campaignId - Campaign ID
* @param {string} options.subscriberId - Subscriber ID
* @returns {Promise} - Promise with the send result
*/
async sendCampaignEmail({ to, subject, preheader, from, content, campaignId, subscriberId }) {
try {
// Generate tracking pixel and add unsubscribe link
const trackingPixel = this.generateTrackingPixel(campaignId, subscriberId);
const unsubscribeLink = this.generateUnsubscribeLink(subscriberId, campaignId);
// Add preheader if provided
let htmlContent = content;
if (preheader) {
htmlContent = `
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
${preheader}
</div>
${htmlContent}
`;
}
// Add tracking pixel and unsubscribe footer
htmlContent = `
${htmlContent}
<div style="margin-top:20px;padding:20px;font-family:sans-serif;font-size:12px;color:#666;text-align:center;border-top:1px solid #eee;">
<p>If you no longer wish to receive these emails, you can <a href="${unsubscribeLink}">unsubscribe here</a>.</p>
<p>Sent by ${config.site.domain}</p>
</div>
${trackingPixel}
`;
// Process links to add tracking
htmlContent = await this.processLinks(htmlContent, campaignId, subscriberId);
// Send the campaign email
return await this.sendEmail({
to,
subject,
html: htmlContent,
from: from || this.defaultFrom,
headers: {
'X-Campaign-ID': campaignId,
'List-Unsubscribe': `<${unsubscribeLink}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
}
});
} catch (error) {
console.error('Error sending campaign email:', error);
throw error;
}
},
/**
* Generate a tracking pixel for email opens
* @param {string} campaignId - Campaign ID
* @param {string} subscriberId - Subscriber ID
* @returns {string} - HTML for the tracking pixel
*/
generateTrackingPixel(campaignId, subscriberId) {
const trackingUrl = `${this.siteUrl}/api/email/track?c=${campaignId}&s=${subscriberId}&t=open`;
return `<img src="${trackingUrl}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
},
/**
* Generate an unsubscribe link
* @param {string} subscriberId - Subscriber ID
* @param {string} campaignId - Campaign ID
* @returns {string} - Unsubscribe URL
*/
generateUnsubscribeLink(subscriberId, campaignId) {
// Generate an unsubscribe token
const token = uuidv4();
this.storeUnsubscribeToken(subscriberId, token, campaignId);
return `${this.siteUrl}/api/subscribers/unsubscribe?token=${token}`;
},
/**
* Store an unsubscribe token in the database
* @param {string} subscriberId - Subscriber ID
* @param {string} token - Unsubscribe token
* @param {string} campaignId - Campaign ID
*/
async storeUnsubscribeToken(subscriberId, token, campaignId) {
try {
// Set token to expire in 60 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 60);
await query(
`INSERT INTO unsubscribe_tokens (subscriber_id, token, campaign_id, expires_at)
VALUES ($1, $2, $3, $4)`,
[subscriberId, token, campaignId, expiresAt]
);
} catch (error) {
console.error('Error storing unsubscribe token:', error);
}
},
/**
* Process HTML content to add tracking to links
* @param {string} content - HTML content
* @param {string} campaignId - Campaign ID
* @param {string} subscriberId - Subscriber ID
* @returns {string} - HTML with tracked links
*/
async processLinks(content, campaignId, subscriberId) {
try {
// Basic regex to find links - in a production environment, use a proper HTML parser
const linkRegex = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi;
let match;
let processedContent = content;
// Store links found in this content
const links = [];
// First pass: collect all links
while ((match = linkRegex.exec(content)) !== null) {
const url = match[1];
const text = match[2];
// Skip tracking for unsubscribe links and mailto links
if (url.includes('/unsubscribe') || url.startsWith('mailto:')) {
continue;
}
// Store the link info
links.push({ url, text });
}
// Store links in the database and get their IDs
const linkIds = await this.storeLinks(links, campaignId);
// Second pass: replace links with tracked versions
let index = 0;
processedContent = content.replace(linkRegex, (match, url, text) => {
// Skip tracking for unsubscribe links and mailto links
if (url.includes('/unsubscribe') || url.startsWith('mailto:')) {
return match;
}
const linkId = linkIds[index++];
if (!linkId) return match; // Skip if no linkId (shouldn't happen)
// Create tracking URL
const trackingUrl = `${this.siteUrl}/api/email/track?c=${campaignId}&s=${subscriberId}&l=${linkId}&t=click&u=${encodeURIComponent(url)}`;
// Replace the original URL with the tracking URL
return match.replace(url, trackingUrl);
});
return processedContent;
} catch (error) {
console.error('Error processing links:', error);
return content; // Return original content on error
}
},
/**
* Store links in the database
* @param {Array} links - Array of link objects { url, text }
* @param {string} campaignId - Campaign ID
* @returns {Array} - Array of link IDs
*/
async storeLinks(links, campaignId) {
try {
const linkIds = [];
for (const link of links) {
// Check if this link already exists for this campaign
const existingLinkCheck = await query(
'SELECT id FROM campaign_links WHERE campaign_id = $1 AND url = $2',
[campaignId, link.url]
);
if (existingLinkCheck.rows.length > 0) {
// Link already exists, use its ID
linkIds.push(existingLinkCheck.rows[0].id);
} else {
// Link doesn't exist, create a new one
const linkId = uuidv4();
await query(
`INSERT INTO campaign_links (id, campaign_id, url, text)
VALUES ($1, $2, $3, $4)`,
[linkId, campaignId, link.url, link.text]
);
linkIds.push(linkId);
}
}
return linkIds;
} catch (error) {
console.error('Error storing links:', error);
return [];
}
},
/**
* Log an email in the database
* @param {Object} emailData - Email data to log
* @param {string} emailData.recipient - Recipient email
* @param {string} emailData.subject - Email subject
* @param {string} emailData.sent_by - User ID who sent the email
* @param {string} [emailData.template_id] - Template ID used
* @param {string} [emailData.template_type] - Template type used
* @returns {Promise<Object>} Log entry
*/
async logEmail(emailData) {
try {
const result = await query(
`INSERT INTO email_logs
(recipient, subject, sent_by, template_id, template_type, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
emailData.recipient,
emailData.subject,
emailData.sent_by,
emailData.template_id || null,
emailData.template_type || null,
'sent'
]
);
return result.rows[0];
} catch (error) {
console.error('Error logging email:', error);
// Don't throw error, just log it
return null;
}
},
/**
* Send a refund confirmation email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.first_name - Customer's first name
* @param {string} options.order_id - Order ID
* @param {string} options.refund_amount - Refund amount
* @param {string} options.refund_date - Refund date
* @param {string} options.refund_method - Refund method (e.g., "Original payment method")
* @param {string} options.refund_reason - Reason for refund
* @returns {Promise<boolean>} Success status
*/
async sendRefundConfirmation(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'refund_confirmation',
variables: options
});
}
};
module.exports = emailService;

View file

@ -1,115 +0,0 @@
const config = require('../config');
const emailService = require('./emailService');
/**
* Service for handling notifications including stock alerts
*/
const notificationService = {
/**
* Process pending low stock notifications
* @param {Object} pool - Database connection pool
* @param {Function} query - Database query function
* @returns {Promise<number>} Number of notifications processed
*/
async processLowStockNotifications(pool, query) {
const client = await pool.connect();
let processedCount = 0;
try {
await client.query('BEGIN');
// Get pending low stock notifications
const pendingNotifications = await client.query(`
SELECT id
FROM notification_logs
WHERE notification_type = 'low_stock_alert'
AND status = 'pending'
LIMIT 50
`);
if (pendingNotifications.rows.length === 0) {
await client.query('COMMIT');
return 0;
}
// Get products with current stock below threshold
const lowStockProducts = await client.query(`
SELECT
p.id,
p.name,
p.stock_quantity,
p.stock_notification
FROM products p
WHERE
p.stock_notification IS NOT NULL
AND p.stock_notification->>'enabled' = 'true'
AND p.stock_notification->>'email' IS NOT NULL
AND p.stock_quantity <= (p.stock_notification->>'threshold')::int
`);
if (lowStockProducts.rows.length === 0) {
// Mark notifications as processed with no action
const notificationIds = pendingNotifications.rows.map(n => n.id);
await client.query(`
UPDATE notification_logs
SET status = 'completed', error_message = 'No eligible products found'
WHERE id = ANY($1)
`, [notificationIds]);
await client.query('COMMIT');
return 0;
}
// Send notifications for each low stock product
for (const product of lowStockProducts.rows) {
console.log("LOW STOCK ON: ", JSON.stringify(product, null, 4))
const notification = product.stock_notification;
try {
// Send email notification using template
await emailService.sendLowStockAlert({
to: notification.email,
product_name: product.name,
current_stock: product.stock_quantity.toString(),
threshold: notification.threshold.toString()
});
// Mark one notification as processed
if (pendingNotifications.rows[processedCount]) {
await client.query(`
UPDATE notification_logs
SET status = 'success'
WHERE id = $1
`, [pendingNotifications.rows[processedCount].id]);
processedCount++;
}
} catch (error) {
console.error(`Error sending low stock notification for product ${product.id}:`, error);
// Mark notification as failed
if (pendingNotifications.rows[processedCount]) {
await client.query(`
UPDATE notification_logs
SET status = 'failed', error_message = $2
WHERE id = $1
`, [pendingNotifications.rows[processedCount].id, error.message]);
processedCount++;
}
}
}
await client.query('COMMIT');
return processedCount;
} catch (error) {
await client.query('ROLLBACK');
console.error('Error processing low stock notifications:', error);
throw error;
} finally {
client.release();
}
}
};
module.exports = notificationService;

View file

@ -1,54 +0,0 @@
// services/queueService.js
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const config = require('../config');
class QueueService {
constructor() {
this.enabled = config.aws.sqs.enabled;
this.sqsClient = null;
if (this.enabled) {
this.sqsClient = new SQSClient({ region: config.aws.region });
}
}
async sendMessage(queueUrl, messageBody) {
if (!this.enabled) {
// In self-hosted mode, execute immediately
console.log('Direct execution (no queue):', messageBody);
await this._processMessageDirectly(messageBody);
return;
}
try {
const command = new SendMessageCommand({
QueueUrl: queueUrl,
MessageBody: JSON.stringify(messageBody)
});
await this.sqsClient.send(command);
} catch (error) {
console.error('Queue send error:', error);
// Fallback to direct processing
await this._processMessageDirectly(messageBody);
}
}
async _processMessageDirectly(messageBody) {
// Direct processing for self-hosted mode
const emailService = require('./emailService');
switch(messageBody.type) {
case 'LOW_STOCK_ALERT':
await emailService.sendLowStockAlert(messageBody);
break;
case 'ORDER_CONFIRMATION':
await emailService.sendOrderConfirmation(messageBody);
break;
// Add other message types
}
}
}
const queueService = new QueueService();
module.exports = queueService;

View file

@ -1,348 +0,0 @@
const axios = require('axios');
const config = require('../config');
/**
* Service for handling shipping operations with EasyPost API
*/
const shippingService = {
/**
* Create a shipment in EasyPost and get available rates with proper IDs
* @param {Object} addressFrom - Shipping origin address
* @param {Object} addressTo - Customer shipping address
* @param {Object} parcelDetails - Package dimensions and weight
* @returns {Promise<Object>} Object containing shipment details and available rates
*/
async getShippingRates(addressFrom, addressTo, parcelDetails) {
// If EasyPost is not enabled, return flat rate shipping
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
console.log("EASY POST NOT CONFIGURED ", !config.shipping.easypostEnabled, !config.shipping.easypostApiKey)
return {
shipment_id: null,
rates: this.getFlatRateShipping(parcelDetails.order_total)
};
}
try {
// Format addresses for EasyPost
const fromAddress = this.formatAddress(addressFrom || config.shipping.originAddress);
const toAddress = this.formatAddress(addressTo);
// Format parcel for EasyPost
const parcel = this.formatParcel(parcelDetails);
// Create shipment first to get proper rate IDs (using v2 API)
const response = await axios.post(
'https://api.easypost.com/v2/shipments',
{
shipment: {
from_address: fromAddress,
to_address: toAddress,
parcel: parcel
}
},
{
auth: {
username: config.shipping.easypostApiKey,
password: ''
},
headers: {
'Content-Type': 'application/json'
}
}
);
const shipment = response.data;
console.log("Shipment created successfully:", shipment.id);
// Process and filter rates from the shipment object
const formattedRates = this.processShippingRates(shipment.rates, parcelDetails.order_total);
// Return both the shipment ID and the rates
return {
shipment_id: shipment.id,
rates: formattedRates
};
} catch (error) {
console.error('EasyPost API error:', error.response?.data || error.message);
// Fallback to flat rate if API fails
return {
shipment_id: null,
rates: this.getFlatRateShipping(parcelDetails.order_total)
};
}
},
/**
* Purchase a selected rate for an existing shipment
* @param {string} shipmentId - The EasyPost shipment ID
* @param {string} rateId - The ID of the selected rate
* @returns {Promise<Object>} Purchased shipment details
*/
async purchaseShipment(shipmentId, rateId) {
// If EasyPost is not enabled or we don't have a shipment ID, return a simulated shipment
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey || !shipmentId) {
console.log("Cannot purchase shipment - EasyPost not configured or no shipment ID");
return this.createSimulatedShipment(rateId);
}
try {
// Buy the selected rate
const buyResponse = await axios.post(
`https://api.easypost.com/v2/shipments/${shipmentId}/buy`,
{
rate: {
id: rateId
}
},
{
auth: {
username: config.shipping.easypostApiKey,
password: ''
},
headers: {
'Content-Type': 'application/json'
}
}
);
const purchasedShipment = buyResponse.data;
console.log("Rate purchased successfully:", purchasedShipment.id);
// Return the important details
return {
shipment_id: purchasedShipment.id,
tracking_code: purchasedShipment.tracking_code,
label_url: purchasedShipment.postage_label.label_url,
selected_rate: {
id: purchasedShipment.selected_rate.id,
carrier: purchasedShipment.selected_rate.carrier,
service: purchasedShipment.selected_rate.service,
rate: parseFloat(purchasedShipment.selected_rate.rate),
delivery_days: purchasedShipment.selected_rate.delivery_days || 'Unknown',
delivery_date: purchasedShipment.selected_rate.delivery_date || null
},
carrier: purchasedShipment.selected_rate.carrier,
service: purchasedShipment.selected_rate.service,
created_at: purchasedShipment.created_at,
status: purchasedShipment.status
};
} catch (error) {
console.error('EasyPost API error during purchasing shipment:', error.response?.data || error.message);
// Return a fallback simulated shipment
return this.createSimulatedShipment(rateId);
}
},
/**
* Create a simulated shipment when EasyPost is not available
* @param {string} rateId - The ID of the selected rate
* @returns {Object} Simulated shipment details
*/
createSimulatedShipment(rateId) {
// Generate a random tracking number
const trackingNumber = `SIMSHIP${Math.floor(Math.random() * 1000000000)}`;
// Return a simulated shipment
return {
shipment_id: `sim_${Date.now()}`,
tracking_code: trackingNumber,
label_url: null,
selected_rate: {
id: rateId,
carrier: rateId.includes('flat') ? 'Standard' : 'Simulated',
service: rateId.includes('flat') ? 'Flat Rate Shipping' : 'Standard Service',
rate: rateId.includes('free') ? 0 : config.shipping.flatRate,
delivery_days: '5-7',
delivery_date: null
},
carrier: rateId.includes('flat') ? 'Standard' : 'Simulated',
service: rateId.includes('flat') ? 'Flat Rate Shipping' : 'Standard Service',
created_at: new Date().toISOString(),
status: 'simulated'
};
},
/**
* Format address for EasyPost API
* @param {Object} address - Address details
* @returns {Object} Formatted address object
*/
formatAddress(address) {
return {
street1: address.street || address.street1,
city: address.city,
state: address.state || address.province,
zip: address.zip || address.postalCode,
country: address.country,
name: address.name || undefined,
company: address.company || undefined,
phone: address.phone || undefined,
email: address.email || undefined
};
},
/**
* Format parcel for EasyPost API
* @param {Object} parcelDetails - Package dimensions and weight
* @returns {Object} Formatted parcel object
*/
formatParcel(parcelDetails) {
const pkg = config.shipping.defaultPackage;
// Convert weight to ounces if coming from grams
const weight = parcelDetails.weight || 500; // Default to 500g if not provided
const weightOz = pkg.weightUnit === 'g' ? weight * 0.035274 : weight;
// Convert dimensions to inches if coming from cm
const lengthConversionFactor = pkg.unit === 'cm' ? 0.393701 : 1;
return {
length: (parcelDetails.length || pkg.length) * lengthConversionFactor,
width: (parcelDetails.width || pkg.width) * lengthConversionFactor,
height: (parcelDetails.height || pkg.height) * lengthConversionFactor,
weight: weightOz,
predefined_package: parcelDetails.predefined_package || null
};
},
/**
* Process and filter shipping rates from EasyPost
* @param {Array} rates - EasyPost shipping rates
* @param {number} orderTotal - Order total amount
* @returns {Array} Processed shipping rates
*/
processShippingRates(rates, orderTotal) {
if (!rates || !Array.isArray(rates)) {
return this.getFlatRateShipping(orderTotal);
}
// Filter by allowed carriers
let filteredRates = rates.filter(rate =>
config.shipping.carriersAllowed.some(carrier =>
rate.carrier.toUpperCase().replaceAll(' ', '').includes(carrier.toUpperCase().replaceAll(' ', ''))
)
);
if (filteredRates.length === 0) {
return this.getFlatRateShipping(orderTotal);
}
// Format rates to standardized format - now including the rate ID
const formattedRates = filteredRates.map(rate => ({
id: rate.id,
carrier: rate.carrier,
service: rate.service,
rate: parseFloat(rate.rate),
currency: rate.currency,
delivery_days: rate.est_delivery_days || rate.delivery_days || 'Unknown',
delivery_date: rate.delivery_date || null,
delivery_time: rate.est_delivery_time || null
}));
// Check if free shipping applies
if (orderTotal >= config.shipping.freeThreshold) {
formattedRates.push({
id: 'free-shipping',
carrier: 'FREE',
service: 'Standard Shipping',
rate: 0,
currency: 'USD',
delivery_days: '5-7',
delivery_date: null,
delivery_time: null
});
}
return formattedRates;
},
/**
* Get flat rate shipping as fallback
* @param {number} orderTotal - Order total amount
* @returns {Array} Flat rate shipping options
*/
getFlatRateShipping(orderTotal) {
const shippingOptions = [{
id: 'flat-rate',
carrier: 'Standard',
service: 'Flat Rate Shipping',
rate: config.shipping.flatRate,
currency: 'USD',
delivery_days: '5-7',
delivery_date: null,
delivery_time: null
}];
// Add free shipping if order qualifies
if (orderTotal >= config.shipping.freeThreshold) {
shippingOptions.push({
id: 'free-shipping',
carrier: 'FREE',
service: 'Standard Shipping',
rate: 0,
currency: 'USD',
delivery_days: '5-7',
delivery_date: null,
delivery_time: null
});
}
return shippingOptions;
},
/**
* Parse shipping address from string format
* @param {string} addressString - Shipping address as string
* @returns {Object} Parsed address object
*/
parseAddressString(addressString) {
const lines = addressString.trim().split('\n').map(line => line.trim());
// Try to intelligently parse the address components
// This is a simplified version - might need enhancement for edge cases
const parsedAddress = {
name: lines[0] || '',
street: lines[1] || '',
city: '',
state: '',
zip: '',
country: lines[lines.length - 1] || ''
};
// Try to parse city, state, zip from line 2
if (lines[2]) {
const cityStateZip = lines[2].split(',');
if (cityStateZip.length >= 2) {
parsedAddress.city = cityStateZip[0].trim();
// Split state and zip
const stateZip = cityStateZip[1].trim().split(' ');
if (stateZip.length >= 2) {
parsedAddress.state = stateZip[0].trim();
parsedAddress.zip = stateZip.slice(1).join(' ').trim();
} else {
parsedAddress.state = stateZip[0].trim();
}
} else {
parsedAddress.city = lines[2];
}
}
return parsedAddress;
},
/**
* Calculate total shipping weight from cart items
* @param {Array} items - Cart items
* @returns {number} Total weight in grams
*/
calculateTotalWeight(items) {
return items.reduce((total, item) => {
const itemWeight = item.weight_grams || 100;
return total + (itemWeight * item.quantity);
}, 0);
}
};
module.exports = shippingService;

View file

@ -1,124 +0,0 @@
const config = require('../config');
const path = require('path');
const fs = require('fs');
/**
* Service for updating site map for dynamic content
*/
const siteMapService = {
/**
* Process site and output SiteMap + robot.txt
* @param {Object} pool - Database connection pool
* @param {Function} query - Database query function
*/
async generateSitemap(pool, query) {
const client = await pool.connect();
try {
await client.query('BEGIN');
console.log('Generating sitemap...');
const siteDomain = config.site.domain;
const protocol = config.site.protocol === 'prod' ? 'https' : 'http';
const baseUrl = `${protocol}://${siteDomain}`;
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<!-- Static pages -->
<url>
<loc>${baseUrl}/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>${baseUrl}/products</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>${baseUrl}/blog</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`;
// Get all products
const productsResult = await client.query(`SELECT id, updated_at FROM products WHERE is_active = true`);
// Add product URLs
for (const product of productsResult.rows) {
const lastmod = new Date(product.updated_at).toISOString().split('T')[0];
sitemap += ` <url>
<loc>${baseUrl}/products/${product.id}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
`;
}
// Get all published blog posts
const blogResult = await client.query(`
SELECT slug, updated_at FROM blog_posts WHERE status = 'published'
`);
// Add blog post URLs
for (const post of blogResult.rows) {
const lastmod = new Date(post.updated_at).toISOString().split('T')[0];
sitemap += ` <url>
<loc>${baseUrl}/blog/${post.slug}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
`;
}
// Get product categories
const categoriesResult = await client.query(`SELECT name FROM product_categories`);
// Add category URLs (assuming they use name as the identifier in the URL)
for (const category of categoriesResult.rows) {
sitemap += ` <url>
<loc>${baseUrl}/products?category=${encodeURIComponent(category.name)}</loc>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>`;
}
sitemap += `</urlset>`;
// Write sitemap to file
const publicDir = path.join(__dirname, '../../public/seo-files');
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
fs.writeFileSync(path.join(publicDir, 'sitemap.xml'), sitemap);
console.log('Sitemap generated successfully at public/sitemap.xml');
// Create robots.txt if it doesn't exist
const robotsTxtPath = path.join(publicDir, 'robots.txt');
if (!fs.existsSync(robotsTxtPath)) {
const robotsTxt = `# robots.txt for ${siteDomain}
User-agent: *
Disallow: /admin/
Disallow: /auth/
Disallow: /checkout/
Disallow: /account/
Disallow: /verify
# Allow sitemaps
Sitemap: ${baseUrl}/sitemap.xml
`;
fs.writeFileSync(robotsTxtPath, robotsTxt);
console.log('robots.txt created successfully');
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
console.error('Error generating sitemap:', error);
} finally {
client.release();
}
}
};
module.exports = siteMapService;

View file

@ -1,83 +0,0 @@
// services/storageService.js
const multer = require('multer');
const multerS3 = require('multer-s3');
const { S3Client } = require('@aws-sdk/client-s3');
const path = require('path');
const fs = require('fs');
const config = require('../config');
class StorageService {
constructor() {
this.mode = config.site.deployment;
this.s3Client = null;
if (this.mode === 'cloud' && config.site.awsS3Bucket) {
this.s3Client = new S3Client({ region: config.site.awsRegion });
}
}
getUploadMiddleware() {
if (this.mode === 'cloud' && config.site.awsS3Bucket) {
// Cloud mode: Use S3
return multer({
storage: multerS3({
s3: this.s3Client,
bucket: config.site.awsS3Bucket,
acl: 'public-read',
key: (req, file, cb) => {
const folder = req.path.includes('/product') ? 'products' : 'blog';
cb(null, `${folder}/${Date.now()}-${file.originalname}`);
}
}),
fileFilter: this._fileFilter,
limits: { fileSize: 10 * 1024 * 1024 }
});
} else {
// Self-hosted mode: Use local storage
return multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../public/uploads');
const folder = req.path.includes('/product') ? 'products' : 'blog';
const finalPath = path.join(uploadDir, folder);
// Ensure directory exists
if (!fs.existsSync(finalPath)) {
fs.mkdirSync(finalPath, { recursive: true });
}
cb(null, finalPath);
},
filename: (req, file, cb) => {
cb(null, `${Date.now()}-${file.originalname}`);
}
}),
fileFilter: this._fileFilter,
limits: { fileSize: 10 * 1024 * 1024 }
});
}
}
_fileFilter(req, file, cb) {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed!'), false);
}
}
getImageUrl(path) {
if (!path) return null;
if (this.mode === 'cloud' && config.site.cdnDomain) {
// Use CloudFront CDN in cloud mode
return `https://${config.site.cdnDomain}${path}`;
} else {
// Use direct path in self-hosted mode
return path;
}
}
}
const storageService = new StorageService();
module.exports = storageService;

View file

@ -1,93 +0,0 @@
const { pool, query } = require('./db');
const config = require('./config');
const notificationService = require('./services/notificationService');
const queueService = require('./services/queueService');
const { Consumer } = require('sqs-consumer');
const { SQSClient } = require('@aws-sdk/client-sqs');
console.log('Starting worker process...');
console.log(`Environment: ${process.env.ENVIRONMENT || 'beta'}`);
console.log(`Deployment mode: ${process.env.DEPLOYMENT_MODE || 'self-hosted'}`);
// Worker initialization
async function initWorker() {
try {
await pool.connect();
console.log('Worker connected to database');
// Set up processing intervals for database-based notifications
const interval = process.env.ENVIRONMENT === 'prod' ? 10 * 60 * 1000 : 2 * 60 * 1000;
setInterval(async () => {
try {
console.log('Processing low stock notifications...');
const processedCount = await notificationService.processLowStockNotifications(pool, query);
console.log(`Processed ${processedCount} low stock notifications`);
} catch (error) {
console.error('Error processing low stock notifications:', error);
}
}, interval);
// For cloud mode, add SQS message consumption here
if (config.aws && config.aws.sqs && config.aws.sqs.enabled && config.aws.sqs.queueUrl) {
console.log(`Starting SQS consumer for queue: ${config.aws.sqs.queueUrl}`);
// Create SQS consumer
const consumer = Consumer.create({
queueUrl: config.aws.sqs.queueUrl,
handleMessage: async (message) => {
try {
console.log('Processing SQS message:', message.MessageId);
const messageBody = JSON.parse(message.Body);
// Use the direct processing method from queueService
await queueService._processMessageDirectly(messageBody);
console.log('Successfully processed message:', message.MessageId);
} catch (error) {
console.error('Error processing message:', message.MessageId, error);
throw error; // Rethrow to handle message as failed
}
},
sqs: new SQSClient({ region: config.aws.region }),
batchSize: 10,
visibilityTimeout: 60,
waitTimeSeconds: 20
});
consumer.on('error', (err) => {
console.error('SQS consumer error:', err.message);
});
consumer.on('processing_error', (err) => {
console.error('SQS message processing error:', err.message);
});
consumer.start();
console.log('SQS consumer started');
}
} catch (error) {
console.error('Worker initialization error:', error);
process.exit(1);
}
}
// Start the worker
initWorker().catch(err => {
console.error('Unhandled worker error:', err);
process.exit(1);
});
// Handle graceful shutdown
process.on('SIGTERM', async () => {
console.log('Worker received SIGTERM, shutting down gracefully');
await pool.end();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('Worker received SIGINT, shutting down gracefully');
await pool.end();
process.exit(0);
});

View file

@ -1,170 +0,0 @@
-- Create UUID extension for generating UUIDs
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login TIMESTAMP WITH TIME ZONE,
current_auth_id UUID, -- Foreign key to authentication table, NULL if logged out
is_active BOOLEAN DEFAULT TRUE
);
-- Create authentication table
CREATE TABLE authentications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(6) NOT NULL, -- 6-digit authentication code
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, -- When this authentication code expires
is_used BOOLEAN DEFAULT FALSE -- Track if this code has been used
);
-- Add foreign key constraint
ALTER TABLE users
ADD CONSTRAINT fk_user_authentication
FOREIGN KEY (current_auth_id)
REFERENCES authentications (id)
ON DELETE SET NULL; -- If auth record is deleted, just set NULL in users table
-- Create product_categories table
CREATE TABLE product_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL UNIQUE,
description TEXT
);
-- Insert the three main product categories
INSERT INTO product_categories (name, description) VALUES
('Rock', 'Natural stone specimens of various types, sizes, and origins'),
('Bone', 'Preserved bones from various sources and species'),
('Stick', 'Natural wooden sticks and branches of different types and sizes');
-- Create products table
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
category_id UUID NOT NULL REFERENCES product_categories(id),
price DECIMAL(10, 2) NOT NULL,
stock_quantity INTEGER NOT NULL DEFAULT 0,
weight_grams DECIMAL(10, 2),
length_cm DECIMAL(10, 2),
width_cm DECIMAL(10, 2),
height_cm DECIMAL(10, 2),
origin VARCHAR(100),
age VARCHAR(100),
material_type VARCHAR(100),
color VARCHAR(100),
image_url VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
);
-- Create orders table
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, processing, shipped, delivered, cancelled
total_amount DECIMAL(10, 2) NOT NULL,
shipping_address TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users (id)
);
-- Create order_items table for order details
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_id UUID NOT NULL,
product_id UUID NOT NULL,
quantity INTEGER NOT NULL,
price_at_purchase DECIMAL(10, 2) NOT NULL, -- Store price at time of purchase
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products (id)
);
-- Create shopping cart table
CREATE TABLE carts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- Create cart items table
CREATE TABLE cart_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cart_id UUID NOT NULL,
product_id UUID NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (cart_id) REFERENCES carts (id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products (id),
UNIQUE(cart_id, product_id) -- Prevent duplicate products in cart
);
-- Create indexes for performance
CREATE INDEX idx_user_email ON users(email);
CREATE INDEX idx_auth_code ON authentications(code);
-- Create tags table
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL UNIQUE,
description TEXT
);
-- Create product_tags junction table
CREATE TABLE product_tags (
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (product_id, tag_id)
);
-- Insert common tags for natural items
INSERT INTO tags (name, description) VALUES
('Polished', 'Items that have been polished to a smooth finish'),
('Raw', 'Items in their natural, unprocessed state'),
('Rare', 'Uncommon or hard-to-find specimens'),
('Fossil', 'Preserved remains or traces of ancient organisms'),
('Decorative', 'Items selected for their aesthetic appeal'),
('Educational', 'Items with significant educational value'),
('Collectible', 'Items that are part of a recognized collection series');
CREATE INDEX idx_product_name ON products(name);
CREATE INDEX idx_product_category ON products(category_id);
CREATE INDEX idx_product_tags_product ON product_tags(product_id);
CREATE INDEX idx_product_tags_tag ON product_tags(tag_id);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
-- Create a function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';
-- Create triggers to automatically update the updated_at column
CREATE TRIGGER update_users_modtime
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_products_modtime
BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_orders_modtime
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_carts_modtime
BEFORE UPDATE ON carts
FOR EACH ROW EXECUTE FUNCTION update_modified_column();

View file

@ -1,185 +0,0 @@
-- Seed data for testing
-- Note: Product categories are already inserted in the schema file, so we're skipping that step
-- Insert rock products
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, weight_grams, color, material_type, origin, image_url)
-- SELECT
-- 'Amethyst Geode',
-- 'Beautiful purple amethyst geode with crystal formation',
-- id,
-- 49.99,
-- 15,
-- 450.0,
-- 'Purple',
-- 'Quartz',
-- 'Brazil',
-- NULL
-- FROM categories WHERE name = 'Rock';
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, weight_grams, color, material_type, origin, image_url)
-- SELECT
-- 'Polished Labradorite',
-- 'Stunning polished labradorite with iridescent blue flash',
-- id,
-- 29.99,
-- 25,
-- 120.5,
-- 'Gray/Blue',
-- 'Feldspar',
-- 'Madagascar',
-- NULL
-- FROM categories WHERE name = 'Rock';
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, weight_grams, color, material_type, origin, image_url)
-- SELECT
-- 'Raw Turquoise',
-- 'Natural turquoise specimen, unpolished',
-- id,
-- 19.99,
-- 18,
-- 85.2,
-- 'Turquoise',
-- 'Turquoise',
-- 'Arizona',
-- NULL
-- FROM categories WHERE name = 'Rock';
-- -- Insert bone products
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, material_type, image_url)
-- SELECT
-- 'Deer Antler',
-- 'Naturally shed deer antler, perfect for display or crafts',
-- id,
-- 24.99,
-- 8,
-- 38.5,
-- 'Antler',
-- NULL
-- FROM categories WHERE name = 'Bone';
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, material_type, image_url)
-- SELECT
-- 'Fossil Fish',
-- 'Well-preserved fossil fish from the Green River Formation',
-- id,
-- 89.99,
-- 5,
-- 22.8,
-- 'Fossilized Bone',
-- NULL
-- FROM categories WHERE name = 'Bone';
-- -- Insert stick products
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, width_cm, material_type, color, image_url)
-- SELECT
-- 'Driftwood Piece',
-- 'Unique driftwood piece, weathered by the ocean',
-- id,
-- 14.99,
-- 12,
-- 45.6,
-- 8.3,
-- 'Driftwood',
-- 'Tan/Gray',
-- NULL
-- FROM categories WHERE name = 'Stick';
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, width_cm, material_type, color, image_url)
-- SELECT
-- 'Walking Stick',
-- 'Hand-selected natural maple walking stick',
-- id,
-- 34.99,
-- 10,
-- 152.4,
-- 3.8,
-- 'Maple',
-- 'Brown',
-- NULL
-- FROM categories WHERE name = 'Stick';
-- WITH categories AS (
-- SELECT id, name FROM product_categories
-- )
-- INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, width_cm, material_type, color, image_url)
-- SELECT
-- 'Decorative Branch Set',
-- 'Set of 3 decorative birch branches for home decoration',
-- id,
-- 19.99,
-- 20,
-- 76.2,
-- 1.5,
-- 'Birch',
-- 'White',
-- NULL
-- FROM categories WHERE name = 'Stick';
-- -- Create a cart for testing
-- INSERT INTO carts (user_id)
-- SELECT id FROM users WHERE email = 'jane@example.com';
-- -- Add product tags - using a different approach
-- -- Tag: Decorative for Amethyst Geode
-- INSERT INTO product_tags (product_id, tag_id)
-- SELECT p.id, t.id
-- FROM products p, tags t
-- WHERE p.name = 'Amethyst Geode' AND t.name = 'Decorative';
-- -- Tag: Polished for Polished Labradorite
-- INSERT INTO product_tags (product_id, tag_id)
-- SELECT p.id, t.id
-- FROM products p, tags t
-- WHERE p.name = 'Polished Labradorite' AND t.name = 'Polished';
-- -- Tag: Raw for Raw Turquoise
-- INSERT INTO product_tags (product_id, tag_id)
-- SELECT p.id, t.id
-- FROM products p, tags t
-- WHERE p.name = 'Raw Turquoise' AND t.name = 'Raw';
-- -- Tags: Fossil and Educational for Fossil Fish
-- INSERT INTO product_tags (product_id, tag_id)
-- SELECT p.id, t.id
-- FROM products p, tags t
-- WHERE p.name = 'Fossil Fish' AND t.name = 'Fossil';
-- INSERT INTO product_tags (product_id, tag_id)
-- SELECT p.id, t.id
-- FROM products p, tags t
-- WHERE p.name = 'Fossil Fish' AND t.name = 'Educational';
-- -- Tag: Decorative for Driftwood Piece
-- INSERT INTO product_tags (product_id, tag_id)
-- SELECT p.id, t.id
-- FROM products p, tags t
-- WHERE p.name = 'Driftwood Piece' AND t.name = 'Decorative';
-- -- Tag: Collectible for Walking Stick
-- INSERT INTO product_tags (product_id, tag_id)
-- SELECT p.id, t.id
-- FROM products p, tags t
-- WHERE p.name = 'Walking Stick' AND t.name = 'Collectible';

View file

@ -1,5 +0,0 @@
-- Add API key column to users table
ALTER TABLE users ADD COLUMN api_key VARCHAR(255) DEFAULT NULL;
-- Create index for faster API key lookups
CREATE INDEX idx_user_api_key ON users(api_key);

View file

@ -1,16 +0,0 @@
-- Create product_images table
CREATE TABLE product_images (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL,
image_path VARCHAR(255) NOT NULL,
display_order INT NOT NULL DEFAULT 0,
is_primary BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX idx_product_images_product_id ON product_images(product_id);
-- Modify existing products table to remove single image_url field
ALTER TABLE products DROP COLUMN IF EXISTS image_url;

View file

@ -1,10 +0,0 @@
-- Add is_admin column to users table
ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;
-- Create index for faster admin lookups
CREATE INDEX idx_user_is_admin ON users(is_admin);
-- Set the first user as admin for testing
-- UPDATE users
-- SET is_admin = TRUE
-- WHERE email = 'shaivkamat@2many.ca';

View file

@ -1 +0,0 @@
ALTER TABLE product_categories ADD COLUMN image_path VARCHAR(255);

View file

@ -1,2 +0,0 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_disabled BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS internal_notes TEXT;

View file

@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS email_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
sent_by UUID NOT NULL REFERENCES users(id),
sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
ip_address VARCHAR(50),
status VARCHAR(50) DEFAULT 'sent'
);

View file

@ -1,69 +0,0 @@
-- Create system_settings table for storing application configuration
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(255) NOT NULL UNIQUE,
value TEXT,
category VARCHAR(100) NOT NULL,
super_req BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create index on key for faster lookups
CREATE INDEX IF NOT EXISTS idx_system_settings_key ON system_settings(key);
-- Create index on category for filtering
CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(category);
-- Insert default settings
INSERT INTO system_settings (key, value, category)
VALUES
-- SMTP Settings
('smtp_host', NULL, 'email', TRUE),
('smtp_port', NULL, 'email', TRUE),
('smtp_user', NULL, 'email', TRUE),
('smtp_password', NULL, 'email', TRUE),
('smtp_from_email', NULL, 'email', TRUE),
('smtp_from_name', NULL, 'email', FALSE),
-- Site Settings
('site_domain', NULL, 'site', TRUE),
('site_api_domain', NULL, 'site', TRUE),
('site_protocol', NULL, 'site', TRUE),
('site_environment', NULL, 'site', TRUE),
('site_deployment', NULL, 'site', TRUE),
('site_redis_host', NULL, 'site', TRUE),
('site_redis_tls', NULL, 'site', TRUE),
('site_aws_region', NULL, 'site', TRUE),
('site_aws_s3_bucket', NULL, 'site', TRUE),
('site_cdn_domain', NULL, 'site', TRUE),
('site_aws_queue_url', NULL, 'site', TRUE),
('site_read_host', NULL, 'site', TRUE),
('site_db_max_connections', NULL, 'site', TRUE),
('site_session_secret', NULL, 'site', TRUE),
('site_redis_port', NULL, 'site', TRUE),
('site_redis_password', NULL, 'site', TRUE),
-- Payment Settings
('currency', 'CAD', 'payment', FALSE),
('tax_rate', '0', 'payment', FALSE),
-- Shipping Settings
('shipping_flat_rate', '10.00', 'shipping', FALSE),
('shipping_free_threshold', '50.00', 'shipping', FALSE),
('shipping_enabled', 'true', 'shipping', FALSE),
('easypost_api_key', NULL, 'shipping', TRUE),
('easypost_enabled', 'false', 'shipping', FALSE),
('shipping_origin_street', '123 Main St', 'shipping', FALSE),
('shipping_origin_city', 'Vancouver', 'shipping', FALSE),
('shipping_origin_state', 'BC', 'shipping', FALSE),
('shipping_origin_zip', 'V6K 1V6', 'shipping', FALSE),
('shipping_origin_country', 'CA', 'shipping', FALSE),
('shipping_default_package_length', '15', 'shipping', FALSE),
('shipping_default_package_width', '12', 'shipping', FALSE),
('shipping_default_package_height', '10', 'shipping', FALSE),
('shipping_default_package_unit', 'cm', 'shipping', FALSE),
('shipping_default_package_weight_unit', 'g', 'shipping', FALSE),
('shipping_carriers_allowed', 'USPS,UPS,FedEx,DHL,Canada Post,Purolator', 'shipping', FALSE)
ON CONFLICT (key) DO NOTHING;

View file

@ -1,22 +0,0 @@
-- Add payment related columns to the orders table
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_completed BOOLEAN DEFAULT FALSE;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_id VARCHAR(255);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(50);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_notes TEXT;
-- Add Stripe settings if they don't exist
INSERT INTO system_settings (key, value, category)
VALUES ('stripe_public_key', '', 'payment')
ON CONFLICT (key) DO NOTHING;
INSERT INTO system_settings (key, value, category)
VALUES ('stripe_secret_key', '', 'payment')
ON CONFLICT (key) DO NOTHING;
INSERT INTO system_settings (key, value, category)
VALUES ('stripe_webhook_secret', '', 'payment')
ON CONFLICT (key) DO NOTHING;
INSERT INTO system_settings (key, value, category)
VALUES ('stripe_enabled', 'false', 'payment')
ON CONFLICT (key) DO NOTHING;

View file

@ -1,16 +0,0 @@
-- Add shipping related columns to the orders table
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_info JSONB;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_date TIMESTAMP;
-- Create a notification logs table to track emails sent
CREATE TABLE IF NOT EXISTS notification_logs (
id SERIAL PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
notification_type VARCHAR(50) NOT NULL,
sent_at TIMESTAMP NOT NULL DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'success',
error_message TEXT
);
-- Create an index on order_id for faster lookups
CREATE INDEX IF NOT EXISTS idx_notification_logs_order_id ON notification_logs(order_id);

View file

@ -1,10 +0,0 @@
-- Add shipping cost column to orders table
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_cost DECIMAL(10, 2) DEFAULT 0.00;
-- Update shipping info to be JSONB if not already
ALTER TABLE orders ALTER COLUMN shipping_info TYPE JSONB
USING CASE
WHEN shipping_info IS NULL THEN NULL
WHEN jsonb_typeof(shipping_info::jsonb) = 'object' THEN shipping_info::jsonb
ELSE jsonb_build_object('data', shipping_info)
END;

View file

@ -1 +0,0 @@
ALTER TABLE carts ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb;

View file

@ -1,53 +0,0 @@
ALTER TABLE products ADD COLUMN IF NOT EXISTS stock_notification JSONB;
-- Create a function to send email notifications when stock drops below threshold
CREATE OR REPLACE FUNCTION notify_low_stock()
RETURNS TRIGGER AS $$
BEGIN
-- Check if notification is enabled and new stock is below threshold
IF (NEW.stock_notification IS NOT NULL AND
NEW.stock_notification->>'enabled' = 'true' AND
NEW.stock_notification->>'email' IS NOT NULL AND
(NEW.stock_notification->>'threshold')::int > 0 AND
NEW.stock_quantity <= (NEW.stock_notification->>'threshold')::int AND
(OLD.stock_quantity IS NULL OR OLD.stock_quantity > (NEW.stock_notification->>'threshold')::int)) THEN
-- Insert notification record into a notification log table
INSERT INTO notification_logs (
order_id, -- Using NULL as this isn't tied to a specific order
notification_type,
sent_at,
status
) VALUES (
NULL,
'low_stock_alert',
NOW(),
'pending'
);
-- Note: The actual email sending will be handled by a backend process
-- that periodically checks for pending notifications
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger to check stock level on update
CREATE TRIGGER check_stock_level_on_update
AFTER UPDATE OF stock_quantity ON products
FOR EACH ROW
EXECUTE FUNCTION notify_low_stock();
-- Create trigger to check stock level on insert (though less common)
CREATE TRIGGER check_stock_level_on_insert
AFTER INSERT ON products
FOR EACH ROW
EXECUTE FUNCTION notify_low_stock();
-- Add stock_notification column to products table
ALTER TABLE products ADD COLUMN IF NOT EXISTS stock_notification JSONB;
-- Create index for faster lookups of products with notifications
CREATE INDEX IF NOT EXISTS idx_products_stock_notification ON products ((stock_notification IS NOT NULL))
WHERE stock_notification IS NOT NULL;
ALTER TABLE notification_logs ALTER COLUMN order_id DROP NOT NULL;

View file

@ -1,65 +0,0 @@
-- Create coupons table
CREATE TABLE coupons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount'
discount_value DECIMAL(10, 2) NOT NULL, -- Percentage or fixed amount value
min_purchase_amount DECIMAL(10, 2), -- Minimum purchase amount to use the coupon (optional)
max_discount_amount DECIMAL(10, 2), -- Maximum discount amount for percentage discounts (optional)
redemption_limit INTEGER, -- NULL means unlimited redemptions
current_redemptions INTEGER NOT NULL DEFAULT 0, -- Track how many times coupon has been used
start_date TIMESTAMP WITH TIME ZONE, -- When the coupon becomes valid (optional)
end_date TIMESTAMP WITH TIME ZONE, -- When the coupon expires (optional)
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Whether the coupon is currently active
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create coupon_categories junction table
CREATE TABLE coupon_categories (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES product_categories(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, category_id)
);
-- Create coupon_tags junction table
CREATE TABLE coupon_tags (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, tag_id)
);
-- Create coupon_blacklist table for excluded products
CREATE TABLE coupon_blacklist (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, product_id)
);
-- Create coupon_redemptions table to track usage
CREATE TABLE coupon_redemptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
discount_amount DECIMAL(10, 2) NOT NULL,
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add applied_coupon_id to orders table
ALTER TABLE orders ADD COLUMN coupon_id UUID REFERENCES coupons(id);
ALTER TABLE orders ADD COLUMN discount_amount DECIMAL(10, 2) DEFAULT 0.00;
-- Add indexes for better performance
CREATE INDEX idx_coupon_code ON coupons(code);
CREATE INDEX idx_coupon_is_active ON coupons(is_active);
CREATE INDEX idx_coupon_end_date ON coupons(end_date);
CREATE INDEX idx_coupon_redemptions_coupon_id ON coupon_redemptions(coupon_id);
CREATE INDEX idx_coupon_redemptions_user_id ON coupon_redemptions(user_id);
-- Create trigger to update the updated_at timestamp
CREATE TRIGGER update_coupons_modtime
BEFORE UPDATE ON coupons
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

View file

@ -1,84 +0,0 @@
-- Create blog post categories
CREATE TABLE blog_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog posts table
CREATE TABLE blog_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL,
excerpt TEXT,
author_id UUID NOT NULL REFERENCES users(id),
category_id UUID REFERENCES blog_categories(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, published, archived
featured_image_path VARCHAR(255),
published_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog post tags junction table
CREATE TABLE blog_post_tags (
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- Create blog post images table
CREATE TABLE blog_post_images (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
image_path VARCHAR(255) NOT NULL,
caption TEXT,
display_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog comments table
CREATE TABLE blog_comments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
parent_id UUID REFERENCES blog_comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create indexes for performance
CREATE INDEX idx_blog_posts_author ON blog_posts(author_id);
CREATE INDEX idx_blog_posts_category ON blog_posts(category_id);
CREATE INDEX idx_blog_posts_status ON blog_posts(status);
CREATE INDEX idx_blog_posts_published_at ON blog_posts(published_at);
CREATE INDEX idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX idx_blog_comments_post ON blog_comments(post_id);
CREATE INDEX idx_blog_comments_user ON blog_comments(user_id);
CREATE INDEX idx_blog_comments_parent ON blog_comments(parent_id);
CREATE INDEX idx_blog_post_images_post ON blog_post_images(post_id);
-- Create triggers to automatically update the updated_at column
CREATE TRIGGER update_blog_categories_modtime
BEFORE UPDATE ON blog_categories
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_blog_posts_modtime
BEFORE UPDATE ON blog_posts
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_blog_comments_modtime
BEFORE UPDATE ON blog_comments
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
-- Insert default blog categories
INSERT INTO blog_categories (name, description) VALUES
('Announcements', 'Official announcements and company news'),
('Collections', 'Information about product collections and releases'),
('Tutorials', 'How-to guides and instructional content'),
('Behind the Scenes', 'Stories about our sourcing and process');

View file

@ -1,80 +0,0 @@
-- Create product reviews table
CREATE TABLE product_reviews (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
parent_id UUID REFERENCES product_reviews(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT,
rating decimal CHECK (rating >= 0.0 AND rating <= 5.0),
is_approved BOOLEAN DEFAULT FALSE,
is_verified_purchase BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add product average rating column to the products table
ALTER TABLE products ADD COLUMN IF NOT EXISTS average_rating DECIMAL(3, 2);
ALTER TABLE products ADD COLUMN IF NOT EXISTS review_count INTEGER DEFAULT 0;
-- Create indexes for performance
CREATE INDEX idx_product_reviews_product ON product_reviews(product_id);
CREATE INDEX idx_product_reviews_user ON product_reviews(user_id);
CREATE INDEX idx_product_reviews_parent ON product_reviews(parent_id);
CREATE INDEX idx_product_reviews_approved ON product_reviews(is_approved);
CREATE INDEX idx_product_reviews_rating ON product_reviews(rating);
-- Create trigger to automatically update the updated_at column
CREATE TRIGGER update_product_reviews_modtime
BEFORE UPDATE ON product_reviews
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
-- Function to update product average rating and review count
CREATE OR REPLACE FUNCTION update_product_average_rating()
RETURNS TRIGGER AS $$
DECLARE
avg_rating DECIMAL(3, 2);
rev_count INTEGER;
BEGIN
-- Calculate average rating and count for approved top-level reviews
SELECT
AVG(rating)::DECIMAL(3, 2),
COUNT(*)
INTO
avg_rating,
rev_count
FROM product_reviews
WHERE product_id = NEW.product_id
AND parent_id IS NULL
AND is_approved = TRUE
AND rating IS NOT NULL;
-- Update the product with new average rating and count
UPDATE products
SET
average_rating = avg_rating,
review_count = rev_count
WHERE id = NEW.product_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers to recalculate average rating when reviews are added/updated/deleted
CREATE TRIGGER update_product_rating_on_insert
AFTER INSERT ON product_reviews
FOR EACH ROW
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();
CREATE TRIGGER update_product_rating_on_update
AFTER UPDATE OF is_approved, rating ON product_reviews
FOR EACH ROW
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();
CREATE TRIGGER update_product_rating_on_delete
AFTER DELETE ON product_reviews
FOR EACH ROW
WHEN (OLD.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();

View file

@ -1,61 +0,0 @@
-- Add email_templates category in system_settings if needed
INSERT INTO system_settings (key, value, category) VALUES
('email_templates_enabled', 'true', 'email')
ON CONFLICT (key) DO NOTHING;
-- Add email_logs table for template logs if it doesn't exist
ALTER TABLE email_logs ADD COLUMN IF NOT EXISTS template_id VARCHAR(255);
ALTER TABLE email_logs ADD COLUMN IF NOT EXISTS template_type VARCHAR(50);
-- Create default login code template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_login_code_default',
'{"name":"Login Code Template","type":"login_code","subject":"Your Login Code","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><h1>Your login code is: {{code}}</h1><p>This code will expire in 15 minutes.</p><p>Or click <a href=\"{{loginLink}}\">here</a> to log in directly.</p></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default shipping notification template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_shipping_notification_default',
'{"name":"Shipping Notification Template","type":"shipping_notification","subject":"Your Order Has Shipped!","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Your Order Has Shipped!</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Good news! Your order has been shipped and is on its way to you.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #4caf50;\"><h3 style=\"margin-top: 0;\">Shipping Details</h3><p><strong>Carrier:</strong> {{carrier}}</p><p><strong>Tracking Number:</strong> <a href=\"{{tracking_link}}\" target=\"_blank\">{{tracking_number}}</a></p><p><strong>Shipped On:</strong> {{shipped_date}}</p><p><strong>Estimated Delivery:</strong> {{estimated_delivery}}</p></div><div style=\"margin-top: 30px;\"><h3>Order Summary</h3><table style=\"width: 100%; border-collapse: collapse;\"><thead><tr style=\"background-color: #f2f2f2;\"><th style=\"padding: 10px; text-align: left;\">Item</th><th style=\"padding: 10px; text-align: left;\">Qty</th><th style=\"padding: 10px; text-align: left;\">Price</th><th style=\"padding: 10px; text-align: left;\">Total</th></tr></thead><tbody>{{items_html}}</tbody></table></div><div style=\"margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;\"><p>Thank you for your purchase!</p></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default order confirmation template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_order_confirmation_default',
'{"name":"Order Confirmation Template","type":"order_confirmation","subject":"Order Confirmation","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Order Confirmation</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Thank you for your order! We are processing it now and will send you another email when it ships.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0;\"><h3 style=\"margin-top: 0;\">Order Details</h3><p><strong>Order Date:</strong> {{order_date}}</p><p><strong>Order Total:</strong> {{order_total}}</p><p><strong>Shipping To:</strong> {{shipping_address}}</p></div><div style=\"margin-top: 30px;\"><h3>Order Summary</h3><table style=\"width: 100%; border-collapse: collapse;\"><thead><tr style=\"background-color: #f2f2f2;\"><th style=\"padding: 10px; text-align: left;\">Item</th><th style=\"padding: 10px; text-align: left;\">Qty</th><th style=\"padding: 10px; text-align: left;\">Price</th><th style=\"padding: 10px; text-align: left;\">Total</th></tr></thead><tbody>{{items_html}}</tbody></table></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default low stock alert template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_low_stock_alert_default',
'{"name":"Low Stock Alert Template","type":"low_stock_alert","subject":"Low Stock Alert: {{product_name}}","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #ff6b6b;\">Low Stock Alert</h1></div><div style=\"padding: 20px;\"><p>Hello,</p><p>This is an automated notification to inform you that the following product is running low on stock:</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #ff6b6b;\"><h3 style=\"margin-top: 0;\">{{product_name}}</h3><p><strong>Current Stock:</strong> {{current_stock}}</p><p><strong>Threshold:</strong> {{threshold}}</p></div><p>You might want to restock this item soon to avoid running out of inventory.</p></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default welcome email template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_welcome_email_default',
'{"name":"Welcome Email Template","type":"welcome_email","subject":"Welcome to Rocks, Bones & Sticks!","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Welcome to Rocks, Bones & Sticks!</h1></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Thank you for creating an account with us. We are excited to have you join our community of natural curiosity enthusiasts!</p><p>As a member, you will enjoy:</p><ul><li>Access to our unique collection of natural specimens</li><li>Special offers and promotions</li><li>Early access to new items</li></ul><p>Start exploring our collections today and discover the beauty of nature!</p><div style=\"margin-top: 30px; text-align: center;\"><a href=\"#\" style=\"background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;\">Shop Now</a></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_refund_confirmation_default',
'{"name":"Refund Confirmation Template","type":"refund_confirmation","subject":"Your Refund for Order #{{order_id}} Has Been Processed","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Refund Confirmation</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>We are writing to confirm that your refund for order #{{order_id}} has been processed. The refund amount of {{refund_amount}} has been issued to your original payment method.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0;\"><h3 style=\"margin-top: 0;\">Refund Details</h3><p><strong>Refund Amount:</strong> {{refund_amount}}</p><p><strong>Refund Date:</strong> {{refund_date}}</p><p><strong>Refund Method:</strong> {{refund_method}}</p><p><strong>Refund Reason:</strong> {{refund_reason}}</p></div><p>Please note that it may take 5-10 business days for the refund to appear in your account, depending on your payment provider.</p><p>If you have any questions about this refund, please do not hesitate to contact our customer support team.</p><p>Thank you for your understanding.</p></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-05-02T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;

View file

@ -1,49 +0,0 @@
-- Add branding category to system_settings if needed
INSERT INTO system_settings (key, value, category) VALUES
('branding_enabled', 'true', 'branding')
ON CONFLICT (key) DO NOTHING;
-- Create default branding settings
INSERT INTO system_settings (key, value, category)
VALUES
('site_name', 'Rocks, Bones & Sticks', 'branding'),
('site_main_page_title', 'Discover Natural Wonders', 'branding'),
('site_main_page_subtitle', 'Unique rocks, bones, and sticks from around my backyard', 'branding'),
('site_main_newsletter_desc', 'Subscribe to our newsletter for updates on new items and promotions', 'branding'),
('site_main_bottom_sting', 'Ready to explore more?', 'branding'),
('site_description', 'Your premier source for natural curiosities and unique specimens', 'branding'),
('site_quicklinks_title', 'Quick Links', 'branding'),
('site_connect', 'Connect With Us', 'branding'),
('blog_title', 'Our Blog', 'branding'),
('blog_desc', 'Discover insights about our natural collections, sourcing adventures, and unique specimens', 'branding'),
('blog_no_content_title', 'No blog posts found', 'branding'),
('blog_no_content_subtitle', 'Check back soon for new content', 'branding'),
('blog_search', 'Search blog posts', 'branding'),
('cart_empty', 'Your Cart is Empty', 'branding'),
('cart_empty_subtitle', 'Looks like you have not added any items to your cart yet.', 'branding'),
('product_title', 'Products', 'branding'),
('orders_title', 'My Orders', 'branding'),
('orders_empty', 'You have not placed any orders yet.', 'branding'),
('default_mode', 'light', 'branding'),
('copyright_text', '© 2025 Rocks, Bones & Sticks. All rights reserved.', 'branding'),
('light_primary_color', '#7e57c2', 'branding'),
('light_secondary_color', '#ffb300', 'branding'),
('light_background_color', '#f5f5f5', 'branding'),
('light_surface_color', '#ffffff', 'branding'),
('light_text_color', '#000000', 'branding'),
('dark_primary_color', '#9575cd', 'branding'),
('dark_secondary_color', '#ffd54f', 'branding'),
('dark_background_color', '#212121', 'branding'),
('dark_surface_color', '#424242', 'branding'),
('dark_text_color', '#ffffff', 'branding'),
('logo_url', '', 'branding'),
('favicon_url', '', 'branding')
ON CONFLICT (key) DO NOTHING;

View file

@ -1,133 +0,0 @@
-- Database schema for mailing list and email campaign management
-- Mailing Lists
CREATE TABLE IF NOT EXISTS mailing_lists (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Subscribers
CREATE TABLE IF NOT EXISTS subscribers (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
first_name VARCHAR(255),
last_name VARCHAR(255),
status VARCHAR(50) DEFAULT 'active', -- active, unsubscribed, bounced, complained
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_activity_at TIMESTAMP
);
-- Mailing List Subscribers (many-to-many)
CREATE TABLE IF NOT EXISTS mailing_list_subscribers (
list_id UUID REFERENCES mailing_lists(id) ON DELETE CASCADE,
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
subscribed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (list_id, subscriber_id)
);
-- Email Campaigns
CREATE TABLE IF NOT EXISTS email_campaigns (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
preheader VARCHAR(255),
from_name VARCHAR(255),
from_email VARCHAR(255) NOT NULL,
content TEXT,
design TEXT, -- JSON storage for the email editor design
list_ids UUID[] NOT NULL, -- Array of list IDs to send to
status VARCHAR(50) DEFAULT 'draft', -- draft, scheduled, sending, sent, archived
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
scheduled_for TIMESTAMP,
sent_at TIMESTAMP
);
-- Campaign Recipients
CREATE TABLE IF NOT EXISTS campaign_recipients (
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (campaign_id, subscriber_id)
);
-- Campaign Links (for click tracking)
CREATE TABLE IF NOT EXISTS campaign_links (
id UUID PRIMARY KEY,
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
url TEXT NOT NULL,
text VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Subscriber Activity
CREATE TABLE IF NOT EXISTS subscriber_activity (
id SERIAL PRIMARY KEY,
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL,
link_id UUID REFERENCES campaign_links(id) ON DELETE SET NULL,
type VARCHAR(50) NOT NULL, -- open, click, bounce, complaint, unsubscribe
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
url TEXT, -- For click events
details TEXT, -- Additional information
bounce_type VARCHAR(50), -- hard, soft
CONSTRAINT valid_activity_type CHECK (
type IN ('open', 'click', 'bounce', 'complaint', 'unsubscribe', 'sent', 'error')
)
);
-- Subscription Confirmations (double opt-in)
CREATE TABLE IF NOT EXISTS subscription_confirmations (
id SERIAL PRIMARY KEY,
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
confirmed_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
-- Unsubscribe Tokens
CREATE TABLE IF NOT EXISTS unsubscribe_tokens (
id SERIAL PRIMARY KEY,
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL,
token VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
used_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
-- Email Logs
ALTER TABLE email_logs
ADD COLUMN IF NOT EXISTS message_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS subscriber_id UUID REFERENCES subscribers(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS error_message TEXT;
-- Create indexes for performance
CREATE INDEX idx_subscribers_email ON subscribers(email);
CREATE INDEX idx_subscribers_status ON subscribers(status);
CREATE INDEX idx_mailing_list_subscribers_list_id ON mailing_list_subscribers(list_id);
CREATE INDEX idx_mailing_list_subscribers_subscriber_id ON mailing_list_subscribers(subscriber_id);
CREATE INDEX idx_email_campaigns_status ON email_campaigns(status);
CREATE INDEX idx_email_campaigns_scheduled_for ON email_campaigns(scheduled_for);
CREATE INDEX idx_campaign_recipients_campaign_id ON campaign_recipients(campaign_id);
CREATE INDEX idx_campaign_recipients_subscriber_id ON campaign_recipients(subscriber_id);
CREATE INDEX idx_campaign_links_campaign_id ON campaign_links(campaign_id);
CREATE INDEX idx_subscriber_activity_subscriber_id ON subscriber_activity(subscriber_id);
CREATE INDEX idx_subscriber_activity_campaign_id ON subscriber_activity(campaign_id);
CREATE INDEX idx_subscriber_activity_type ON subscriber_activity(type);
CREATE INDEX idx_subscriber_activity_timestamp ON subscriber_activity(timestamp);
CREATE INDEX idx_subscription_confirmations_token ON subscription_confirmations(token);
CREATE INDEX idx_unsubscribe_tokens_token ON unsubscribe_tokens(token);
CREATE INDEX idx_email_logs_recipient ON email_logs(recipient);
CREATE INDEX idx_email_logs_campaign_id ON email_logs(campaign_id);
CREATE INDEX idx_email_logs_status ON email_logs(status);
INSERT INTO mailing_lists (id, name, description) VALUES
('1db91b9b-b1f9-4892-80b5-51437d8b6045', 'Default Mailing List', 'This is the default mailing list that new users who accept are attached to, do not delete, feel free to rename');

View file

@ -1,4 +0,0 @@
-- Add refund-related columns to order_items table
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS refunded BOOLEAN DEFAULT FALSE;
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS refunded_quantity INTEGER;
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS refund_reason TEXT;

View file

@ -1 +0,0 @@
ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS design JSONB;

View file

@ -1,9 +0,0 @@
-- Create superadmins table that links to the users table
CREATE TABLE superadmins (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
description TEXT
);
-- Create index for faster lookups
CREATE INDEX idx_superadmins_user_id ON superadmins(user_id);

View file

@ -1,67 +0,0 @@
#!/bin/bash
echo "Starting backend service..."
docker compose up -d db backend
echo "Waiting for backend to start..."
sleep 5
echo "Testing API health endpoint..."
curl -s http://localhost:4000/health | jq
echo "Fetching product categories..."
curl -s http://localhost:4000/api/products/categories/all | jq
echo "Fetching all products..."
curl -s http://localhost:4000/api/products | jq
echo "Fetching Rock category products..."
curl -s http://localhost:4000/api/products/category/Rock | jq
echo "Testing user registration..."
curl -s -X POST http://localhost:4000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","firstName":"Test","lastName":"User"}' | jq
echo "Testing login request..."
curl -s -X POST http://localhost:4000/api/auth/login-request \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com"}' | jq
# Save the auth code for the next step
AUTH_CODE=$(curl -s -X POST http://localhost:4000/api/auth/login-request \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com"}' | jq -r '.code')
echo "Testing login verification with code: $AUTH_CODE"
RESPONSE=$(curl -s -X POST http://localhost:4000/api/auth/verify \
-H "Content-Type: application/json" \
-d "{\"email\":\"test@example.com\",\"code\":\"$AUTH_CODE\"}")
echo $RESPONSE | jq
USER_ID=$(echo $RESPONSE | jq -r '.userId')
API_KEY=$(echo $RESPONSE | jq -r '.token')
echo "User ID: $USER_ID"
echo "API Key: $API_KEY"
echo "Verifying API key..."
curl -s -X POST http://localhost:4000/api/auth/verify-key \
-H "Content-Type: application/json" \
-d "{\"apiKey\":\"$API_KEY\"}" | jq
echo "Testing cart for user: $USER_ID with API key"
curl -s http://localhost:4000/api/cart/$USER_ID \
-H "X-API-Key: $API_KEY" | jq
echo "Adding a product to the cart..."
PRODUCT_ID=$(curl -s http://localhost:4000/api/products | jq -r '.[0].id')
curl -s -X POST http://localhost:4000/api/cart/add \
-H "Content-Type: application/json" \
-d "{\"userId\":\"$USER_ID\",\"productId\":\"$PRODUCT_ID\",\"quantity\":1}" | jq
echo "Showing updated cart..."
curl -s http://localhost:4000/api/cart/$USER_ID | jq
echo "Test complete!"

View file

@ -1,111 +0,0 @@
version: '3.8'
services:
# Frontend React application
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
- seo-volume:/app/public
depends_on:
- backend
networks:
- app-network
# Backend Express server
backend:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- ./backend/.env
ports:
- "${PORT:-4000}:4000"
volumes:
- ./backend:/app
- /app/node_modules
- ./backend/public/uploads:/app/public/uploads
- seo-volume:/app/public/seo-files
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
required: false
networks:
- app-network
# PostgreSQL Database
db:
image: postgres:14-alpine
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d
env_file:
- ./backend/.env
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
networks:
- app-network
# Redis service - only active in cloud mode
redis:
image: redis:alpine
command: ["sh", "-c", "redis-server ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"]
volumes:
- redis_data:/data
networks:
- app-network
profiles:
- cloud
healthcheck:
test: ["CMD", "redis-cli", "${REDIS_PASSWORD:+--pass}", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 5s
retries: 5
# Background worker for SQS and job processing - only active in cloud mode
worker:
build:
context: ./backend
dockerfile: Dockerfile
command: node src/worker.js
env_file:
- ./backend/.env
environment:
- WORKER_MODE=true
volumes:
- ./backend:/app
- /app/node_modules
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
required: false
networks:
- app-network
profiles:
- cloud
restart: always
volumes:
postgres_data:
redis_data:
seo-volume:
networks:
app-network:
driver: bridge

View file

@ -1,236 +0,0 @@
/ (root)
├── .env
├── .gitignore
├── Dockerfile
├── README.md
├── config.js
├── docker-compose.yml
├── fileStructure.txt
├── index.html
├── main.jsx
├── nginx.conf
├── package.json
├── setup-frontend.sh
├── start.sh
├── vite.config.js
├── backend/
│ ├── node_modules/
│ ├── public/
│ │ ├── seo-files/
│ │ ├── uploads/
│ │ ├── robots.txt
│ │ └── sitemap.xml
│ │
│ └── src/
│ ├── db/
│ │ └── index.js
│ │
│ ├── middleware/
│ │ ├── adminAuth.js
│ │ ├── auth.js
│ │ ├── seoMiddleware.js
│ │ └── upload.js
│ │
│ ├── models/
│ │ └── SystemSettings.js
│ │
│ ├── routes/
│ │ ├── auth.js
│ │ ├── blog.js
│ │ ├── blogAdmin.js
│ │ ├── blogCommentsAdmin.js
│ │ ├── cart.js
│ │ ├── categoryAdmin.js
│ │ ├── couponAdmin.js
│ │ ├── emailCampaignsAdmin.js
│ │ ├── emailTemplatesAdmin.js
│ │ ├── emailTracking.js
│ │ ├── images.js
│ │ ├── mailingListAdmin.js
│ │ ├── orderAdmin.js
│ │ ├── productAdmin.js
│ │ ├── productAdminImages.js
│ │ ├── productReviews.js
│ │ ├── productReviewsAdmin.js
│ │ ├── products.js
│ │ ├── publicSettings.js
│ │ ├── settingsAdmin.js
│ │ ├── shipping.js
│ │ ├── stripePayment.js
│ │ ├── subscribers.js
│ │ ├── subscribersAdmin.js
│ │ ├── userAdmin.js
│ │ └── userOrders.js
│ │
│ ├── services/
│ │ ├── cacheService.js
│ │ ├── emailService.js
│ │ ├── notificationService.js
│ │ ├── queueService.js
│ │ ├── shippingService.js
│ │ ├── sitemapService.js
│ │ └── storageService.js
│ │
│ └── uploads/
│ ├── temp/
│ ├── config.js
│ ├── index.js
│ └── worker.js
├── db/
│ ├── init/
│ │ ├── 01-schema.sql
│ │ ├── 02-seed.sql
│ │ ├── 03-api-key.sql
│ │ ├── 04-product-images.sql
│ │ ├── 05-admin-role.sql
│ │ ├── 06-product-categories.sql
│ │ ├── 07-user-keys.sql
│ │ ├── 08-create-email.sql
│ │ ├── 09-system-settings.sql
│ │ ├── 10-payment.sql
│ │ ├── 11-notifications.sql
│ │ ├── 12-shipping-orders.sql
│ │ ├── 13-cart-metadata.sql
│ │ ├── 14-product-notifications.sql
│ │ ├── 15-coupon.sql
│ │ ├── 16-blog-schema.sql
│ │ ├── 17-product-reviews.sql
│ │ ├── 18-email-templates.sql
│ │ ├── 19-branding-settings.sql
│ │ ├── 20-mailinglist.sql
│ │ ├── 21-order-refund.sql
│ │ └── 22-blog-json.sql
│ │
│ └── test/
└── frontend/
├── node_modules/
├── public/
│ ├── seo-files/
│ └── favicon.svg
└── src/
├── assets/
├── components/
│ ├── CookieConsentPopup.jsx
│ ├── CookieSettingsButton.jsx
│ ├── CouponInput.jsx
│ ├── EmailDialog.jsx
│ ├── Footer.jsx
│ ├── ImageUploader.jsx
│ ├── Notifications.jsx
│ ├── OrderStatusDialog.jsx
│ ├── ProductImage.jsx
│ ├── ProductRatingDisplay.jsx
│ ├── ProductReviews.jsx
│ ├── ProtectedRoute.jsx
│ ├── SEOMetaTags.jsx
│ ├── StripePaymentForm.jsx
│ └── SubscriptionForm.jsx
├── context/
│ └── StripeContext.jsx
├── features/
│ ├── auth/
│ │ └── authSlice.js
│ ├── cart/
│ │ └── cartSlice.js
│ └── ui/
│ └── uiSlice.js
├── hooks/
│ ├── adminHooks.js
│ ├── apiHooks.js
│ ├── blogHooks.js
│ ├── brandingHooks.js
│ ├── categoryAdminHooks.js
│ ├── couponAdminHooks.js
│ ├── emailCampaignHooks.js
│ ├── emailTemplateHooks.js
│ ├── productAdminHooks.js
│ ├── productReviewHooks.js
│ ├── reduxHooks.js
│ ├── settingsAdminHooks.js
│ ├── useProductSeo.js
│ ├── useSeoMeta.js
│ └── useSeoUrl.js
├── layouts/
│ ├── AdminLayout.jsx
│ ├── AuthLayout.jsx
│ └── MainLayout.jsx
├── pages/
│ ├── Admin/
│ │ ├── BlogCommentsPage.jsx
│ │ ├── BlogEditPage.jsx
│ │ ├── BlogPage.jsx
│ │ ├── BlogPreviewPage.jsx
│ │ ├── BrandingPage.jsx
│ │ ├── CampaignAnalyticsPage.jsx
│ │ ├── CampaignSendPage.jsx
│ │ ├── CategoriesPage.jsx
│ │ ├── CouponsPage.jsx
│ │ ├── CustomersPage.jsx
│ │ ├── DashboardPage.jsx
│ │ ├── EmailCampaignEditorPage.jsx
│ │ ├── EmailCampaignsPage.jsx
│ │ ├── EmailTemplatesPage.jsx
│ │ ├── MailingListsPage.jsx
│ │ ├── OrdersPage.jsx
│ │ ├── ProductEditPage.jsx
│ │ ├── ProductReviewsPage.jsx
│ │ ├── ProductsPage.jsx
│ │ ├── ReportsPage.jsx
│ │ ├── SettingsPage.jsx
│ │ └── SubscribersPage.jsx
│ │
│ ├── BlogDetailPage.jsx
│ ├── BlogPage.jsx
│ ├── CartPage.jsx
│ ├── CheckoutPage.jsx
│ ├── CouponEditPage.jsx
│ ├── CouponRedemptionsPage.jsx
│ ├── HomePage.jsx
│ ├── LoginPage.jsx
│ ├── NotFoundPage.jsx
│ ├── PaymentCancelPage.jsx
│ ├── PaymentSuccessPage.jsx
│ ├── ProductDetailPage.jsx
│ ├── ProductsPage.jsx
│ ├── RegisterPage.jsx
│ ├── SubscriptionConfirmPage.jsx
│ ├── SubscriptionPreferencesPage.jsx
│ ├── UnsubscribePage.jsx
│ ├── UserOrdersPage.jsx
│ └── VerifyPage.jsx
├── services/
│ ├── adminService.js
│ ├── api.js
│ ├── authService.js
│ ├── blogAdminService.js
│ ├── blogService.js
│ ├── cartService.js
│ ├── categoryAdminService.js
│ ├── consentService.js
│ ├── couponService.js
│ ├── emailTemplateService.js
│ ├── imageService.js
│ ├── productReviewsService.js
│ ├── productService.js
│ └── settingsAdminService.js
├── store/
│ └── index.js
├── theme/
│ ├── index.js
│ └── ThemeProvider.jsx
└── utils/
├── imageUtils.js
└── App.jsx

View file

@ -1,58 +0,0 @@
# FROM node:18-alpine as build
# WORKDIR /app
# # Copy package files first for better layer caching
# COPY package*.json ./
# # Use npm install instead of npm ci to resolve dependency mismatches
# RUN npm install
# # Copy the rest of the application
# COPY . .
# # Make sure we have a public directory for static assets
# RUN mkdir -p public
# # Build the application
# RUN npm run build
# # Production stage
# FROM nginx:alpine
# # Copy built assets from the build stage
# COPY --from=build /app/dist /usr/share/nginx/html
# # Copy custom nginx config if it exists, otherwise create a basic one
# # COPY nginx.conf /etc/nginx/conf.d/default.conf 2>/dev/null || echo 'server { \
# # listen 80; \
# # root /usr/share/nginx/html; \
# # index index.html; \
# # location / { \
# # try_files $uri $uri/ /index.html; \
# # } \
# # location /api/ { \
# # proxy_pass http://backend:4000; \
# # } \
# # }' > /etc/nginx/conf.d/default.conf
# EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"]
# frontend/Dockerfile.dev
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
# Don't copy source files - they will be mounted as a volume
# COPY . .
# Expose the Vite dev server port
EXPOSE 3000
# Run the dev server
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View file

@ -1,66 +0,0 @@
# Rocks, Bones & Sticks Frontend
React frontend for the Rocks, Bones & Sticks e-commerce platform.
## Technologies Used
- **React**: UI library
- **React Router**: For navigation and routing
- **Redux**: For state management
- **@reduxjs/toolkit**: Simplified Redux development
- **React Query**: For data fetching, caching, and state management
- **Material UI**: Component library with theming
- **Axios**: HTTP client
- **Vite**: Build tool
## Project Structure
```
src/
├── assets/ # Static assets like images, fonts
├── components/ # Reusable components
├── features/ # Redux slices organized by feature
├── hooks/ # Custom hooks
├── layouts/ # Layout components
├── pages/ # Page components
├── services/ # API services
├── store/ # Redux store configuration
├── theme/ # Material UI theme configuration
└── utils/ # Utility functions
```
## Setup
```bash
# Install dependencies
npm install
# Run for development
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
```
## Environment Variables
Create a `.env` file with the following variables:
```
VITE_APP_NAME=Rocks, Bones & Sticks
VITE_API_URL=http://localhost:4000/api
VITE_ENVIRONMENT=beta # Use 'beta' for development, 'prod' for production
```
## Features
- **User Authentication**: Login with email verification code
- **Product Browsing**: Filter and search products
- **Shopping Cart**: Add, update, remove items
- **Checkout Process**: Complete order creation
- **Admin Dashboard**: Manage products, view orders and customers
- **Responsive Design**: Works on mobile and desktop devices
- **Dark Mode Support**: Toggle between light and dark themes

View file

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="google-site-verification" content="8M21caNH2SqraajQ9DMd7bo9XV4qWvvO92UzLqIkk70" />
<meta name="msvalidate.01" content="595831D58CBA42A913AC8F386CEBDFFE" />
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rocks, Bones & Sticks</title>
<meta name="description" content="Your premier source for natural curiosities and unique specimens" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View file

@ -1,37 +0,0 @@
server {
listen 80;
server_name localhost;
# Document root
root /usr/share/nginx/html;
index index.html;
# Handle SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend
location /api/ {
proxy_pass http://backend:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Static asset caching
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
gzip_disable "MSIE [1-6]\.";
}

View file

@ -1,47 +0,0 @@
{
"name": "rocks-bones-sticks-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.8",
"@microsoft/clarity": "^1.0.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.19",
"@reduxjs/toolkit": "^2.0.1",
"@stripe/react-stripe-js": "^2.4.0",
"@stripe/stripe-js": "^2.2.0",
"@tanstack/react-query": "^5.12.2",
"axios": "^1.6.2",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0",
"papaparse": "^5.5.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-email-editor": "^1.7.11",
"react-helmet": "^6.1.0",
"react-redux": "^9.0.2",
"react-router-dom": "^6.20.1",
"recharts": "^2.10.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"sass": "^1.69.5",
"vite": "^5.0.0"
}
}

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3L4 9v12h16V9l-8-6z" stroke="#673AB7" fill="#EDE7F6" />
<path d="M12 9a2 2 0 100 4 2 2 0 000-4z" stroke="#673AB7" fill="#673AB7" />
<path d="M8 21v-5a4 4 0 118 0v5" stroke="#673AB7" />
</svg>

Before

Width:  |  Height:  |  Size: 392 B

View file

@ -1,33 +0,0 @@
#!/bin/bash
# Create React app using Vite
npm create vite@latest frontend -- --template react
# Navigate to frontend directory
cd frontend
# Install core dependencies
npm install \
react-router-dom \
@reduxjs/toolkit \
react-redux \
@tanstack/react-query \
@tanstack/react-query-devtools \
axios \
@mui/material \
@mui/icons-material \
@emotion/react \
@emotion/styled \
@fontsource/roboto
# Install dev dependencies
npm install -D \
@types/react \
@types/react-dom \
@vitejs/plugin-react \
sass
# Create frontend project structure
mkdir -p src/{assets,components,features,hooks,layouts,pages,services,store,theme,utils}
echo "Frontend project setup complete!"

View file

@ -1,237 +0,0 @@
import { Routes, Route } from 'react-router-dom';
import { Suspense, lazy, useEffect } from 'react';
import { CircularProgress, Box } from '@mui/material';
import Notifications from '@components/Notifications';
import ProtectedRoute from '@components/ProtectedRoute';
import { StripeProvider } from './context/StripeContext';
import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
import Clarity from '@microsoft/clarity';
import CookieConsentPopup from '@components/CookieConsentPopup';
// Import layouts
import MainLayout from './layouts/MainLayout';
import AuthLayout from './layouts/AuthLayout';
import AdminLayout from './layouts/AdminLayout';
// Pages - lazy loaded to reduce initial bundle size
const HomePage = lazy(() => import('@pages/HomePage'));
const ProductsPage = lazy(() => import('@pages/ProductsPage'));
const ProductDetailPage = lazy(() => import('@pages/ProductDetailPage'));
const CartPage = lazy(() => import('@pages/CartPage'));
const CheckoutPage = lazy(() => import('@pages/CheckoutPage'));
const PaymentSuccessPage = lazy(() => import('@pages/PaymentSuccessPage'));
const PaymentCancelPage = lazy(() => import('@pages/PaymentCancelPage'));
const LoginPage = lazy(() => import('@pages/LoginPage'));
const RegisterPage = lazy(() => import('@pages/RegisterPage'));
const VerifyPage = lazy(() => import('@pages/VerifyPage'));
const AdminDashboardPage = lazy(() => import('@pages/Admin/DashboardPage'));
const AdminProductsPage = lazy(() => import('@pages/Admin/ProductsPage'));
const AdminProductEditPage = lazy(() => import('@pages/Admin/ProductEditPage'));
const AdminCategoriesPage = lazy(() => import('@pages/Admin/CategoriesPage'));
const AdminCustomersPage = lazy(() => import('@pages/Admin/CustomersPage'));
const AdminOrdersPage = lazy(() => import('@pages/Admin/OrdersPage'));
const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage'));
const AdminReportsPage = lazy(() => import('@pages/Admin/ReportsPage'));
const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage'));
const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
const CouponsPage = lazy(() => import('@pages/Admin/CouponsPage'));
const CouponEditPage = lazy(() => import('@pages/CouponEditPage'));
const CouponRedemptionsPage = lazy(() => import('@pages/CouponRedemptionsPage'));
const BlogPage = lazy(() => import('@pages/BlogPage'));
const BlogDetailPage = lazy(() => import('@pages/BlogDetailPage'));
const AdminBlogPage = lazy(() => import('@pages/Admin/BlogPage'));
const BlogEditPage = lazy(() => import('@pages/Admin/BlogEditPage'));
const BlogPreviewPage = lazy(() => import('@pages/Admin/BlogPreviewPage'));
const AdminBlogCommentsPage = lazy(() => import('@pages/Admin/BlogCommentsPage'));
const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPage'));
const EmailTemplatesPage = lazy(() => import('@pages/Admin/EmailTemplatesPage'));
const BrandingPage = lazy(() => import('@pages/Admin/BrandingPage'));
const EmailCampaignsPage = lazy(() => import('@pages/Admin/EmailCampaignsPage'));
const EmailCampaignEditor = lazy(() => import('@pages/Admin/EmailCampaignEditorPage'));
const CampaignSendPage = lazy(() => import('@pages/Admin/CampaignSendPage'));
const CampaignAnalyticsPage = lazy(() => import('@pages/Admin/CampaignAnalyticsPage'));
const MailingListsPage = lazy(() => import('@pages/Admin/MailingListsPage'));
const SubscribersPage = lazy(() => import('@pages/Admin/SubscribersPage'));
const SubscriptionConfirmPage = lazy(() => import('@pages/SubscriptionConfirmPage'));
const UnsubscribePage = lazy(() => import('@pages/UnsubscribePage'));
const SubscriptionPreferencesPage = lazy(() => import('@pages/SubscriptionPreferencesPage'));
const projectId = "rcjhrd0t72"
// Loading component for suspense fallback
const LoadingComponent = () => (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
);
function App() {
// Use the centralized hook to fetch branding settings
const { data: brandingSettings, isLoading } = useBrandingSettings();
useEffect(() => {
Clarity.init(projectId);
Clarity.consent({ clarity: false });
}, []);
// Update the document head with branding settings
useEffect(() => {
if (brandingSettings) {
// Update document title
if (brandingSettings?.site_name) {
document.title = brandingSettings?.site_name;
}
// Update favicon if available
if (brandingSettings?.favicon_url) {
// Remove any existing favicon links
const existingLinks = document.querySelectorAll("link[rel*='icon']");
existingLinks.forEach(link => link.parentNode.removeChild(link));
// Create and add new favicon link
const link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = imageUtils.getImageUrl(brandingSettings?.favicon_url); //brandingSettings?.favicon_url;
document.head.appendChild(link);
// Also add Apple touch icon for iOS devices
const touchIcon = document.createElement('link');
touchIcon.rel = 'apple-touch-icon';
touchIcon.href = imageUtils.getImageUrl(brandingSettings?.favicon_url);
document.head.appendChild(touchIcon);
console.log(link);
}
// Add meta description if available
if (brandingSettings?.site_description) {
// Remove any existing description meta tags
const existingMeta = document.querySelector("meta[name='description']");
if (existingMeta) {
existingMeta.parentNode.removeChild(existingMeta);
}
// Create and add new meta description
const meta = document.createElement('meta');
meta.name = 'description';
meta.content = brandingSettings?.site_description;
document.head.appendChild(meta);
}
}
}, [brandingSettings]);
return (
<StripeProvider>
<Suspense fallback={<LoadingComponent />}>
<Notifications />
<CookieConsentPopup />
<Routes>
{/* Main routes with MainLayout */}
<Route path="/" element={<MainLayout />}>
<Route path="account/orders" element={
<ProtectedRoute>
<UserOrdersPage />
</ProtectedRoute>
} />
<Route index element={
<Suspense fallback={<LoadingComponent />}>
<HomePage />
</Suspense>
} />
<Route path="products" element={
<Suspense fallback={<LoadingComponent />}>
<ProductsPage />
</Suspense>
} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="confirm-subscription" element={<SubscriptionConfirmPage />} />
<Route path="unsubscribe" element={<UnsubscribePage />} />
<Route path="subscription-preferences/:token" element={<SubscriptionPreferencesPage />} />
<Route path="cart" element={
<ProtectedRoute>
<CartPage />
</ProtectedRoute>
} />
<Route path="checkout" element={
<ProtectedRoute>
<CheckoutPage />
</ProtectedRoute>
} />
{/* Payment success and cancel routes */}
<Route path="checkout/success" element={
<ProtectedRoute>
<PaymentSuccessPage />
</ProtectedRoute>
} />
<Route path="checkout/cancel" element={
<ProtectedRoute>
<PaymentCancelPage />
</ProtectedRoute>
} />
{/* Blog routes */}
<Route path="blog" element={<BlogPage />} />
<Route path="blog/:slug" element={<BlogDetailPage />} />
</Route>
{/* Auth routes with AuthLayout */}
<Route path="/auth" element={<AuthLayout />}>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
</Route>
{/* Verification route - standalone page */}
<Route path="/verify" element={<VerifyPage />} />
{/* Admin routes with AdminLayout - protected for admins only */}
<Route path="/admin" element={
<ProtectedRoute requireAdmin={true} redirectTo="/">
<AdminLayout />
</ProtectedRoute>
}>
<Route index element={<AdminDashboardPage />} />
<Route path="products" element={<AdminProductsPage />} />
<Route path="products/:id" element={<AdminProductEditPage />} />
<Route path="products/new" element={<AdminProductEditPage />} />
<Route path="categories" element={<AdminCategoriesPage />} />
<Route path="customers" element={<AdminCustomersPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="reports" element={<AdminReportsPage />} />
<Route path="coupons" element={<CouponsPage />} />
<Route path="coupons/new" element={<CouponEditPage />} />
<Route path="coupons/:id" element={<CouponEditPage />} />
<Route path="coupons/:id/redemptions" element={<CouponRedemptionsPage />} />
<Route path="blog" element={<AdminBlogPage />} />
<Route path="blog/preview" element={<BlogPreviewPage />} />
<Route path="blog/new" element={<BlogEditPage />} />
<Route path="blog/:id" element={<BlogEditPage />} />
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />
<Route path="product-reviews" element={<AdminProductReviewsPage />} />
<Route path="email-templates" element={<EmailTemplatesPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="email-campaigns" element={<EmailCampaignsPage />} />
<Route path="email-campaigns/new" element={<EmailCampaignEditor />} />
<Route path="email-campaigns/:id" element={<EmailCampaignEditor />} />
<Route path="email-campaigns/:id/send" element={<CampaignSendPage />} />
<Route path="email-campaigns/:id/analytics" element={<CampaignAnalyticsPage />} />
<Route path="mailing-lists" element={<MailingListsPage />} />
<Route path="mailing-lists/:listId/subscribers" element={<SubscribersPage />} />
</Route>
{/* Catch-all route for 404s */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</StripeProvider>
);
}
export default App;

View file

@ -1,193 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Card,
CardContent,
Typography,
Button,
Box,
IconButton,
Radio,
RadioGroup,
FormControlLabel,
FormControl,
FormLabel,
Slide,
Divider
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import CookieIcon from '@mui/icons-material/Cookie';
import consentService, { CONSENT_LEVELS } from '../services/consentService';
/**
* Cookie consent popup component
* Shows a popup for cookie consent on first visit and manages Clarity tracking
*/
const CookieConsentPopup = () => {
const [open, setOpen] = useState(false);
const [consentLevel, setConsentLevel] = useState(CONSENT_LEVELS.NECESSARY);
useEffect(() => {
// Check if user has already set cookie preferences
const storedConsent = consentService.getStoredConsent();
if (!storedConsent) {
// First visit, show the popup
setOpen(true);
} else {
// Apply stored consent settings
setConsentLevel(storedConsent.level);
consentService.applyConsentSettings(storedConsent.level);
}
}, []);
// Handle consent level change
const handleConsentLevelChange = (event) => {
setConsentLevel(event.target.value);
};
// Save user preferences and close popup
const handleSave = () => {
consentService.saveConsent(consentLevel);
setOpen(false);
};
// Accept all cookies
const handleAcceptAll = () => {
const allConsent = CONSENT_LEVELS.ALL;
setConsentLevel(allConsent);
consentService.saveConsent(allConsent);
setOpen(false);
};
// Accept only necessary cookies
const handleNecessaryOnly = () => {
const necessaryOnly = CONSENT_LEVELS.NECESSARY;
setConsentLevel(necessaryOnly);
consentService.saveConsent(necessaryOnly);
setOpen(false);
};
// Reopen the consent popup (for testing or if user wants to change settings)
// This function can be exposed via a preferences button somewhere
const reopenConsentPopup = () => {
setOpen(true);
};
if (!open) return null;
return (
<Slide direction="up" in={open} mountOnEnter unmountOnExit>
<Card
sx={{
position: 'fixed',
bottom: 20,
right: 20,
maxWidth: 400,
boxShadow: 4,
zIndex: 9999
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CookieIcon color="primary" fontSize="small" sx={{ mr: 1 }} />
<Typography variant="h6">Cookie Settings</Typography>
</Box>
<IconButton size="small" onClick={() => setOpen(false)}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" paragraph>
We use cookies to enhance your browsing experience, provide personalized content,
and analyze our traffic. Please choose your privacy settings below.
</Typography>
<FormControl component="fieldset" sx={{ my: 2 }}>
<FormLabel component="legend">Privacy Settings</FormLabel>
<RadioGroup
value={consentLevel}
onChange={handleConsentLevelChange}
>
<FormControlLabel
value={CONSENT_LEVELS.NECESSARY}
control={<Radio />}
label={
<Typography variant="body2">
<strong>Necessary Cookies Only</strong> - Essential for website functionality
</Typography>
}
/>
<FormControlLabel
value={CONSENT_LEVELS.FUNCTIONAL}
control={<Radio />}
label={
<Typography variant="body2">
<strong>Functional Cookies</strong> - For enhanced functionality and preferences
</Typography>
}
/>
<FormControlLabel
value={CONSENT_LEVELS.ANALYTICS}
control={<Radio />}
label={
<Typography variant="body2">
<strong>Analytics Cookies</strong> - To understand how you use our website
</Typography>
}
/>
<FormControlLabel
value={CONSENT_LEVELS.ALL}
control={<Radio />}
label={
<Typography variant="body2">
<strong>All Cookies</strong> - Accept all cookies for the best experience
</Typography>
}
/>
</RadioGroup>
</FormControl>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
variant="outlined"
onClick={handleNecessaryOnly}
size="small"
>
Necessary Only
</Button>
<Box>
<Button
variant="contained"
onClick={handleSave}
sx={{ mr: 1 }}
size="small"
>
Save Preferences
</Button>
<Button
variant="outlined"
onClick={handleAcceptAll}
size="small"
>
Accept All
</Button>
</Box>
</Box>
</CardContent>
</Card>
</Slide>
);
};
// Expose reopen function for others to use
export const reopenCookieConsent = () => {
const consentElement = document.getElementById('cookie-consent-reopen');
if (consentElement) {
consentElement.click();
}
};
export default CookieConsentPopup;

View file

@ -1,166 +0,0 @@
import React, { useState } from 'react';
import { Button, IconButton, Tooltip, Dialog, DialogTitle, DialogContent,
DialogActions, Typography, Box, Divider, RadioGroup, Radio, FormControlLabel,
FormControl, FormLabel } from '@mui/material';
import CookieIcon from '@mui/icons-material/Cookie';
import consentService, { CONSENT_LEVELS } from '../services/consentService';
/**
* Button to open cookie settings dialog
* Can be placed in the footer or other accessible areas of the app
*/
const CookieSettingsButton = ({ variant = 'text', icon = false, label = 'Cookie Settings' }) => {
const [open, setOpen] = useState(false);
const [consentLevel, setConsentLevel] = useState(() => {
const storedConsent = consentService.getStoredConsent();
return storedConsent?.level || CONSENT_LEVELS.NECESSARY;
});
const handleOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const handleConsentLevelChange = (event) => {
setConsentLevel(event.target.value);
};
const handleSave = () => {
consentService.saveConsent(consentLevel);
setOpen(false);
};
const handleAcceptAll = () => {
const allConsent = CONSENT_LEVELS.ALL;
setConsentLevel(allConsent);
consentService.saveConsent(allConsent);
setOpen(false);
};
const handleNecessaryOnly = () => {
const necessaryOnly = CONSENT_LEVELS.NECESSARY;
setConsentLevel(necessaryOnly);
consentService.saveConsent(necessaryOnly);
setOpen(false);
};
// Render as icon button or regular button
if (icon) {
return (
<>
<Tooltip title={label}>
<IconButton onClick={handleOpen} size="small" color="inherit">
<CookieIcon fontSize="small" />
</IconButton>
</Tooltip>
{renderDialog()}
</>
);
}
return (
<>
<Button variant={variant} size="small" onClick={handleOpen}>
{label}
</Button>
{renderDialog()}
</>
);
function renderDialog() {
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CookieIcon sx={{ mr: 1 }} />
Cookie Settings
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body2" paragraph>
We use cookies to enhance your browsing experience, provide personalized content,
and analyze our traffic. You can adjust your privacy settings below.
</Typography>
<Divider sx={{ my: 2 }} />
<FormControl component="fieldset" sx={{ width: '100%' }}>
<FormLabel component="legend">Privacy Settings</FormLabel>
<RadioGroup
value={consentLevel}
onChange={handleConsentLevelChange}
>
<FormControlLabel
value={CONSENT_LEVELS.NECESSARY}
control={<Radio />}
label={
<Box>
<Typography variant="body1">Necessary Cookies Only</Typography>
<Typography variant="body2" color="text.secondary">
Essential cookies required for basic website functionality. These cannot be disabled.
</Typography>
</Box>
}
/>
<FormControlLabel
value={CONSENT_LEVELS.FUNCTIONAL}
control={<Radio />}
label={
<Box>
<Typography variant="body1">Functional Cookies</Typography>
<Typography variant="body2" color="text.secondary">
Cookies that remember your preferences and enhance website functionality.
</Typography>
</Box>
}
/>
<FormControlLabel
value={CONSENT_LEVELS.ANALYTICS}
control={<Radio />}
label={
<Box>
<Typography variant="body1">Analytics Cookies</Typography>
<Typography variant="body2" color="text.secondary">
Cookies that help us understand how you use our website and improve your experience.
This includes Microsoft Clarity for analyzing site usage patterns.
</Typography>
</Box>
}
/>
<FormControlLabel
value={CONSENT_LEVELS.ALL}
control={<Radio />}
label={
<Box>
<Typography variant="body1">All Cookies</Typography>
<Typography variant="body2" color="text.secondary">
Accept all cookies including analytics, marketing, and third-party cookies.
</Typography>
</Box>
}
/>
</RadioGroup>
</FormControl>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">
You can change these settings at any time by clicking on the Cookie Settings link in the footer.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleNecessaryOnly} color="inherit">Necessary Only</Button>
<Box sx={{ flexGrow: 1 }} />
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant="contained">Save Preferences</Button>
<Button onClick={handleAcceptAll} variant="outlined">Accept All</Button>
</DialogActions>
</Dialog>
);
}
};
export default CookieSettingsButton;

View file

@ -1,138 +0,0 @@
import React, { useState } from 'react';
import {
TextField,
Button,
Box,
Typography,
CircularProgress,
Alert,
Paper,
Divider,
Chip
} from '@mui/material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import couponService from '@services/couponService';
import { useAuth } from '@hooks/reduxHooks';
/**
* Component for inputting and applying coupon codes to the cart
*/
const CouponInput = () => {
const [couponCode, setCouponCode] = useState('');
const [error, setError] = useState(null);
const { user } = useAuth();
const queryClient = useQueryClient();
// Get current cart data from cache
const cartData = queryClient.getQueryData(['cart', user]);
// Apply coupon mutation
const applyCoupon = useMutation({
mutationFn: ({ userId, code }) => couponService.applyCoupon(userId, code),
onSuccess: (data) => {
// Update React Query cache directly
queryClient.setQueryData(['cart', user], data);
setError(null);
},
onError: (error) => {
setError(error.message || 'Failed to apply coupon');
},
});
// Remove coupon mutation
const removeCoupon = useMutation({
mutationFn: (userId) => couponService.removeCoupon(userId),
onSuccess: (data) => {
// Update React Query cache directly
queryClient.setQueryData(['cart', user], data);
setError(null);
},
onError: (error) => {
setError(error.message || 'Failed to remove coupon');
},
});
// Handle coupon code input change
const handleCouponChange = (e) => {
setCouponCode(e.target.value.toUpperCase());
setError(null);
};
// Handle applying coupon
const handleApplyCoupon = () => {
if (!couponCode) {
setError('Please enter a coupon code');
return;
}
applyCoupon.mutate({ userId: user, code: couponCode });
};
// Handle removing coupon
const handleRemoveCoupon = () => {
removeCoupon.mutate(user);
setCouponCode('');
};
// Check if a coupon is already applied
const hasCoupon = cartData?.couponCode;
return (
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Discount Code
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{hasCoupon ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Chip
label={cartData.couponCode}
color="success"
sx={{ mr: 2 }}
/>
<Typography variant="body2" color="success.main">
Discount applied: -${cartData.couponDiscount?.toFixed(2)}
</Typography>
</Box>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemoveCoupon}
disabled={removeCoupon.isLoading}
>
{removeCoupon.isLoading ? <CircularProgress size={24} /> : 'Remove Coupon'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex' }}>
<TextField
fullWidth
placeholder="Enter coupon code"
value={couponCode}
onChange={handleCouponChange}
disabled={applyCoupon.isLoading}
inputProps={{ style: { textTransform: 'uppercase' } }}
sx={{ mr: 2 }}
/>
<Button
variant="contained"
onClick={handleApplyCoupon}
disabled={!couponCode || applyCoupon.isLoading}
>
{applyCoupon.isLoading ? <CircularProgress size={24} /> : 'Apply'}
</Button>
</Box>
)}
</Paper>
);
};
export default CouponInput;

View file

@ -1,150 +0,0 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
CircularProgress,
Alert,
Typography
} from '@mui/material';
import { useSendEmail } from '../hooks/adminHooks';
/**
* Email Dialog component for sending emails to customers
* @param {Object} props - Component props
* @param {boolean} props.open - Whether the dialog is open
* @param {Function} props.onClose - Function to call when dialog is closed
* @param {Object} props.recipient - Recipient user object (includes email, first_name, last_name)
*/
const EmailDialog = ({ open, onClose, recipient }) => {
const [formData, setFormData] = useState({
subject: '',
message: ''
});
const [formErrors, setFormErrors] = useState({});
// Send email mutation
const sendEmail = useSendEmail();
// Handle form changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear validation error
if (formErrors[name]) {
setFormErrors(prev => ({ ...prev, [name]: '' }));
}
};
// Handle form submission
const handleSubmit = () => {
// Validate form
const errors = {};
if (!formData.subject.trim()) {
errors.subject = 'Subject is required';
}
if (!formData.message.trim()) {
errors.message = 'Message is required';
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
// Submit form
sendEmail.mutate({
to: recipient.email,
name: `${recipient.first_name} ${recipient.last_name}`,
subject: formData.subject,
message: formData.message
}, {
onSuccess: () => {
handleClose();
}
});
};
// Handle dialog close
const handleClose = () => {
// Reset form
setFormData({
subject: '',
message: ''
});
setFormErrors({});
// Close dialog
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>
Send Email to Customer
{recipient && (
<Typography variant="subtitle1">
Recipient: {recipient.first_name} {recipient.last_name} ({recipient.email})
</Typography>
)}
</DialogTitle>
<DialogContent>
{sendEmail.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
{sendEmail.error?.message || 'Failed to send email. Please try again.'}
</Alert>
)}
<TextField
autoFocus
margin="dense"
label="Subject"
name="subject"
fullWidth
variant="outlined"
value={formData.subject}
onChange={handleChange}
error={!!formErrors.subject}
helperText={formErrors.subject}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="Message"
name="message"
fullWidth
multiline
rows={8}
variant="outlined"
value={formData.message}
onChange={handleChange}
error={!!formErrors.message}
helperText={formErrors.message}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button
onClick={handleSubmit}
variant="contained"
color="primary"
disabled={sendEmail.isLoading}
>
{sendEmail.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Send Email
</Button>
</DialogActions>
</Dialog>
);
};
export default EmailDialog;

View file

@ -1,116 +0,0 @@
import React from 'react';
import { Box, Container, Grid, Typography, Link, IconButton, Divider } from '@mui/material';
import FacebookIcon from '@mui/icons-material/Facebook';
import TwitterIcon from '@mui/icons-material/Twitter';
import InstagramIcon from '@mui/icons-material/Instagram';
import { Link as RouterLink } from 'react-router-dom';
import imageUtils from '@utils/imageUtils';
import CookieSettingsButton from './CookieSettingsButton';
const Footer = ({brandingSettings}) => {
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
const copyrightText = brandingSettings?.copyright_text ||
`© ${new Date().getFullYear()} ${siteName}. All rights reserved.`;
const logoUrl = imageUtils.getImageUrl(brandingSettings?.logo_url)
return (
<Box
component="footer"
sx={{
py: 3,
px: 2,
mt: 'auto',
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[200]
: theme.palette.grey[800],
}}
>
<Container maxWidth="lg">
<Grid container spacing={3}>
<Grid item xs={12} sm={4}>
{brandingSettings?.logo_url ? (
<Box
component="img"
src={logoUrl}
alt={siteName}
sx={{
height: 40,
maxWidth: '100%',
mb: 2,
objectFit: 'contain'
}}
/>
) : (
<Typography variant="h6" color="text.primary" gutterBottom>
{siteName}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
{ brandingSettings?.site_description || `Your premier source for natural curiosities
and unique specimens from around the world.`}
</Typography>
</Grid>
<Grid item xs={12} sm={4} align="center">
<Typography variant="h6" color="text.primary" gutterBottom>
{brandingSettings?.site_quicklinks_title || `Quick Links`}
</Typography>
<Link component={RouterLink} to="/" color="inherit" display="block">
Home
</Link>
<Link component={RouterLink} to="/products" color="inherit" display="block">
Shop All
</Link>
<Link component={RouterLink} to="/blog" color="inherit" display="block">
Blog
</Link>
</Grid>
<Grid item xs={12} sm={4}>
<Typography variant="h6" color="text.primary" gutterBottom>
{brandingSettings?.site_connect || `Connect With Us`}
</Typography>
<Box>
<IconButton aria-label="facebook" color="primary">
<FacebookIcon />
</IconButton>
<IconButton aria-label="twitter" color="primary">
<TwitterIcon />
</IconButton>
<IconButton aria-label="instagram" color="primary">
<InstagramIcon />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{brandingSettings?.site_main_newsletter_desc || `Subscribe to our newsletter for updates on new items and promotions.`}
</Typography>
</Grid>
</Grid>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', gap: 2 }}>
{/* <Link component={RouterLink} to="/privacy-policy" color="inherit" variant="body2">
Privacy Policy
</Link>
<Link component={RouterLink} to="/terms-of-service" color="inherit" variant="body2">
Terms of Service
</Link> */}
<CookieSettingsButton />
</Box>
</Box>
<Box mt={3}>
<Typography variant="body2" color="text.secondary" align="center">
{copyrightText}
</Typography>
</Box>
</Container>
</Box>
);
};
export default Footer;

View file

@ -1,225 +0,0 @@
import React, { useState } from 'react';
import {
Box,
Button,
Typography,
CircularProgress,
Alert,
Grid,
IconButton,
Card,
CardMedia,
CardActions,
Tooltip
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import { useAuth } from '@hooks/reduxHooks';
import imageUtils from '@utils/imageUtils';
/**
* Image uploader component for product images
* @param {Object} props - Component props
* @param {Array} props.images - Current images
* @param {Function} props.onChange - Callback when images change
* @param {boolean} props.multiple - Whether to allow multiple images
* @param {boolean} props.admin - Whether this is for admin use
* @param {string} props.inputId - Unique ID for the file input element
* @returns {JSX.Element} - Image uploader component
*/
const ImageUploader = ({
images = [],
onChange,
multiple = true,
admin = true,
inputId = `image-upload-input-${Math.random().toString(36).substring(2, 9)}`
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const { apiKey } = useAuth();
const handleUpload = async (event) => {
const files = event.target.files;
if (!files || files.length === 0) return;
setLoading(true);
setError(null);
try {
if (multiple && files.length > 1) {
// Upload multiple images
const response = await imageUtils.uploadMultipleImages(
Array.from(files),
{ apiKey, isProductImage: true }
);
// Format new images
const newImages = [...images];
response.images.forEach((img, index) => {
newImages.push({
path: img.imagePath,
isPrimary: images.length === 0 && index === 0,
displayOrder: images.length + index
});
});
onChange(newImages);
} else {
// Upload single image
const response = await imageUtils.uploadImage(
files[0],
{ apiKey, isProductImage: true }
);
// Add the new image
const newImage = {
path: response.imagePath,
isPrimary: images.length === 0,
displayOrder: images.length
};
onChange([...images, newImage]);
}
} catch (err) {
console.error('Image upload failed:', err);
setError(err.message || 'Failed to upload image. Please try again.');
} finally {
setLoading(false);
}
};
const handleRemoveImage = (index) => {
// Get the image to remove
const imageToRemove = images[index];
// Extract filename if we need to delete from server
const filename = imageUtils.getFilenameFromPath(imageToRemove.path);
// Remove from array first
const newImages = [...images];
newImages.splice(index, 1);
// If the removed image was primary, make the first one primary
if (imageToRemove.isPrimary && newImages.length > 0) {
newImages[0].isPrimary = true;
}
// Update state
onChange(newImages);
// If we have the filename and this is admin mode, delete from server
if (admin && filename) {
imageUtils.deleteImage(filename, { apiKey })
.catch(err => console.error('Failed to delete image from server:', err));
}
};
const handleSetPrimary = (index) => {
// Update all images, setting only one as primary
const newImages = images.map((img, i) => ({
...img,
isPrimary: i === index
}));
onChange(newImages);
};
return (
<Box>
{/* Hidden file input with unique ID */}
<input
type="file"
multiple={multiple}
accept="image/*"
style={{ display: 'none' }}
id={inputId}
onChange={handleUpload}
/>
{/* Upload button with matching htmlFor */}
<label htmlFor={inputId}>
<Button
variant="outlined"
component="span"
startIcon={<CloudUploadIcon />}
disabled={loading}
sx={{ mb: 2 }}
>
{loading ? (
<>
Uploading...
<CircularProgress size={24} sx={{ ml: 1 }} />
</>
) : (
`Upload Image${multiple ? 's' : ''}`
)}
</Button>
</label>
{/* Error message */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Image grid */}
{images.length > 0 ? (
<Grid container spacing={2} sx={{ mt: 1 }}>
{images.map((image, index) => (
<Grid item xs={6} sm={4} md={3} key={index}>
<Card
elevation={2}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: image.isPrimary ? '2px solid' : 'none',
borderColor: 'primary.main'
}}
>
<CardMedia
component="img"
sx={{ height: 140, objectFit: 'cover' }}
image={imageUtils.getImageUrl(image.path)}
alt={`Image ${index + 1}`}
/>
<CardActions sx={{ justifyContent: 'space-between', mt: 'auto' }}>
<Tooltip title={image.isPrimary ? "Primary Image" : "Set as Primary"}>
<IconButton
size="small"
color={image.isPrimary ? "primary" : "default"}
onClick={() => handleSetPrimary(index)}
disabled={image.isPrimary}
>
{image.isPrimary ? <StarIcon /> : <StarBorderIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Remove Image">
<IconButton
size="small"
color="error"
onClick={() => handleRemoveImage(index)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</CardActions>
</Card>
</Grid>
))}
</Grid>
) : (
<Typography color="text.secondary" sx={{ mt: 2 }}>
No images uploaded yet. Click the button above to upload.
</Typography>
)}
</Box>
);
};
export default ImageUploader;

View file

@ -1,39 +0,0 @@
import React from 'react';
import { Snackbar, Alert } from '@mui/material';
import { useAppSelector, useAppDispatch } from '../hooks/reduxHooks';
import { selectNotifications, removeNotification } from '../features/ui/uiSlice';
const Notifications = () => {
const notifications = useAppSelector(selectNotifications);
const dispatch = useAppDispatch();
const handleClose = (id) => {
dispatch(removeNotification(id));
};
return (
<>
{notifications.map((notification) => (
<Snackbar
key={notification.id}
open={true}
autoHideDuration={notification.duration || 6000}
onClose={() => handleClose(notification.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={() => handleClose(notification.id)}
severity={notification.type || 'info'}
sx={{ width: '100%' }}
elevation={6}
variant="filled"
>
{notification.message}
</Alert>
</Snackbar>
))}
</>
);
};
export default Notifications;

View file

@ -1,361 +0,0 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
CircularProgress,
Grid,
Typography,
Divider,
Box,
Alert
} from '@mui/material';
import { useUpdateOrderStatus, useRefundOrder } from '@hooks/adminHooks';
// List of supported shipping carriers
const SHIPPING_CARRIERS = [
{ value: 'Canada Post', label: 'Canada Post' },
{ value: 'Purolator', label: 'Purolator' },
{ value: 'USPS', label: 'USPS' },
{ value: 'UPS', label: 'UPS' },
{ value: 'FedEx', label: 'FedEx' },
{ value: 'DHL', label: 'DHL' },
{ value: 'other', label: 'Other (specify)' }
];
// List of refund reasons
const REFUND_REASONS = [
{ value: 'customer_request', label: 'Customer Request' },
{ value: 'item_damaged', label: 'Item Arrived Damaged' },
{ value: 'item_missing', label: 'Items Missing from Order' },
{ value: 'wrong_item', label: 'Wrong Item Received' },
{ value: 'quality_issue', label: 'Quality Not as Expected' },
{ value: 'late_delivery', label: 'Excessive Shipping Delay' },
{ value: 'order_mistake', label: 'Mistake in Order Processing' },
{ value: 'other', label: 'Other' }
];
const OrderStatusDialog = ({ open, onClose, order }) => {
const [status, setStatus] = useState(order ? order.status : 'pending');
const [shippingData, setShippingData] = useState({
shipper: '',
otherShipper: '',
trackingNumber: '',
shipmentId: '',
estimatedDelivery: '',
customerMessage: ''
});
const [refundData, setRefundData] = useState({
reason: 'customer_request',
customReason: '',
sendEmail: true
});
const updateOrderStatus = useUpdateOrderStatus();
const refundOrder = useRefundOrder();
const handleStatusChange = (e) => {
setStatus(e.target.value);
};
const handleShippingDataChange = (e) => {
const { name, value } = e.target;
setShippingData(prev => ({
...prev,
[name]: value
}));
};
const handleRefundDataChange = (e) => {
const { name, value } = e.target;
setRefundData(prev => ({
...prev,
[name]: value
}));
};
const handleCheckboxChange = (e) => {
const { name, checked } = e.target;
setRefundData(prev => ({
...prev,
[name]: checked
}));
};
const handleSave = () => {
// For shipped status, require tracking number
if (status === 'shipped' && !shippingData.trackingNumber) {
alert('Please enter a tracking number');
return;
}
// Determine the actual shipper value to send
let shipperValue = shippingData.shipper;
if (shipperValue === 'other' && shippingData.otherShipper) {
shipperValue = shippingData.otherShipper;
}
// For refunded status, use the refund mutation
if (status === 'refunded') {
// Determine the actual reason value to send
let reasonValue = refundData.reason;
if (reasonValue === 'other' && refundData.customReason) {
reasonValue = refundData.customReason;
}
refundOrder.mutate({
orderId: order.id,
reason: reasonValue,
sendEmail: refundData.sendEmail
}, {
onSuccess: () => {
onClose();
}
});
} else if (status === 'shipped') {
// For shipped status, include shipping information
updateOrderStatus.mutate({
orderId: order.id,
status,
shippingData: {
...shippingData,
shipper: shipperValue,
// Add current date as shipped date if not provided
shippedDate: new Date().toISOString().split('T')[0]
},
sendNotification: true
}, {
onSuccess: () => {
onClose();
}
});
} else {
// For other statuses, just update the status
updateOrderStatus.mutate({
orderId: order.id,
status
}, {
onSuccess: () => {
onClose();
}
});
}
};
const isPending = updateOrderStatus.isLoading || refundOrder.isLoading;
const error = updateOrderStatus.error || refundOrder.error;
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Update Order Status</DialogTitle>
<DialogContent dividers>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error.message || 'An error occurred while updating the order.'}
</Alert>
)}
<Grid container spacing={3}>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel id="status-select-label">Status</InputLabel>
<Select
labelId="status-select-label"
value={status}
label="Status"
onChange={handleStatusChange}
>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="processing">Processing</MenuItem>
<MenuItem value="shipped">Shipped</MenuItem>
<MenuItem value="delivered">Delivered</MenuItem>
<MenuItem value="cancelled">Cancelled</MenuItem>
<MenuItem value="refunded">Refunded</MenuItem>
</Select>
</FormControl>
</Grid>
{status === 'shipped' && (
<>
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle1" gutterBottom>
Shipping Information
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
This information will be sent to the customer via email.
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="shipper-select-label">Shipping Carrier</InputLabel>
<Select
labelId="shipper-select-label"
name="shipper"
value={shippingData.shipper}
label="Shipping Carrier"
onChange={handleShippingDataChange}
>
{SHIPPING_CARRIERS.map((carrier) => (
<MenuItem key={carrier.value} value={carrier.value}>
{carrier.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{shippingData.shipper === 'other' && (
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Specify Carrier"
name="otherShipper"
value={shippingData.otherShipper}
onChange={handleShippingDataChange}
placeholder="Enter carrier name"
/>
</Grid>
)}
<Grid item xs={12} md={shippingData.shipper === 'other' ? 12 : 6}>
<TextField
fullWidth
required
label="Tracking Number"
name="trackingNumber"
value={shippingData.trackingNumber}
onChange={handleShippingDataChange}
placeholder="Enter tracking number"
error={status === 'shipped' && !shippingData.trackingNumber}
helperText={status === 'shipped' && !shippingData.trackingNumber ? 'Tracking number is required' : ''}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Shipment ID"
name="shipmentId"
value={shippingData.shipmentId}
onChange={handleShippingDataChange}
placeholder="Optional internal reference"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Estimated Delivery"
name="estimatedDelivery"
value={shippingData.estimatedDelivery}
onChange={handleShippingDataChange}
placeholder="e.g., 3-5 business days"
/>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<TextField
fullWidth
multiline
rows={4}
label="Message to Customer"
name="customerMessage"
value={shippingData.customerMessage}
onChange={handleShippingDataChange}
placeholder="Add a personal message to the shipping notification email (optional)"
/>
</Grid>
</>
)}
{status === 'refunded' && (
<>
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle1" gutterBottom>
Refund Information
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
The refund will be processed through Stripe to the customer's original payment method.
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="refund-reason-label">Refund Reason</InputLabel>
<Select
labelId="refund-reason-label"
name="reason"
value={refundData.reason}
label="Refund Reason"
onChange={handleRefundDataChange}
>
{REFUND_REASONS.map((reason) => (
<MenuItem key={reason.value} value={reason.value}>
{reason.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{refundData.reason === 'other' && (
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Specify Reason"
name="customReason"
value={refundData.customReason}
onChange={handleRefundDataChange}
placeholder="Enter specific reason for refund"
/>
</Grid>
)}
<Grid item xs={12}>
<FormControl fullWidth>
<div style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
id="sendEmail"
name="sendEmail"
checked={refundData.sendEmail}
onChange={(e) => handleCheckboxChange(e)}
style={{ marginRight: '8px' }}
/>
<label htmlFor="sendEmail">
Send refund confirmation email to customer
</label>
</div>
</FormControl>
</Grid>
</>
)}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
color="primary"
disabled={isPending}
>
{isPending ? <CircularProgress size={24} /> : 'Save'}
</Button>
</DialogActions>
</Dialog>
);
};
export default OrderStatusDialog;

View file

@ -1,67 +0,0 @@
import React, { useState, memo } from 'react';
import { Box } from '@mui/material';
import imageUtils from '@utils/imageUtils';
/**
* Component to display product images with fallback handling
* @param {Object} props - Component props
* @param {Array|Object|string} props.images - Product images array, single image object, or image path
* @param {string} props.alt - Alt text for the image
* @param {Object} props.sx - Additional styling props
* @param {string} props.placeholderImage - Placeholder image to use when no image is available
* @returns {JSX.Element} - ProductImage component
*/
const ProductImage = memo(({
images,
alt = 'Product image',
sx = {},
placeholderImage = "https://placehold.co/600x400/000000/FFFF",
...rest
}) => {
const [imageError, setImageError] = useState(false);
let imageSrc = placeholderImage;
// Handle different types of image inputs
if (Array.isArray(images) && images.length > 0) {
// Find primary image in the array
const primaryImage = images.find(img => img.isPrimary || img.is_primary) || images[0];
const imagePath = primaryImage.path || primaryImage.image_path || primaryImage;
imageSrc = imageUtils.getImageUrl(imagePath, placeholderImage);
} else if (typeof images === 'object' && images !== null) {
// Single image object
const imagePath = images.path || images.image_path;
imageSrc = imageUtils.getImageUrl(imagePath, placeholderImage);
} else if (typeof images === 'string' && images) {
// Direct image path
imageSrc = imageUtils.getImageUrl(images, placeholderImage);
}
// Handle image load errors
const handleError = () => {
console.error(`Failed to load image: ${imageSrc}`);
if (!imageError) {
setImageError(true);
}
};
return (
<Box
component="img"
src={imageError ? placeholderImage : imageSrc}
alt={alt}
onError={handleError}
loading="lazy"
sx={{
display: 'block',
width: '100%',
height: 'auto',
objectFit: 'cover',
...sx
}}
{...rest}
/>
);
})
export default ProductImage;

View file

@ -1,27 +0,0 @@
import React, { memo } from 'react';
import { Box, Typography, Rating } from '@mui/material';
/**
* Component to display product rating in a compact format
*/
const ProductRatingDisplay = memo(({ rating, reviewCount, showEmpty = false }) => {
if (!rating && !reviewCount && !showEmpty) {
return null;
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
<Rating
value={rating || 0}
readOnly
precision={0.5}
size="small"
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
{reviewCount ? `(${reviewCount})` : showEmpty ? '(0)' : ''}
</Typography>
</Box>
);
})
export default ProductRatingDisplay;

View file

@ -1,328 +0,0 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Rating,
Avatar,
Button,
TextField,
Paper,
Divider,
Card,
CardContent,
FormControlLabel,
Checkbox,
Alert,
CircularProgress
} from '@mui/material';
import { format } from 'date-fns';
import CommentIcon from '@mui/icons-material/Comment';
import VerifiedIcon from '@mui/icons-material/Verified';
import { useProductReviews, useCanReviewProduct, useAddProductReview } from '@hooks/productReviewHooks';
import { useAuth } from '@hooks/reduxHooks';
/**
* Component for displaying product reviews and allowing users to submit new reviews
*/
const ProductReviews = ({ productId }) => {
const { isAuthenticated } = useAuth();
const [replyTo, setReplyTo] = useState(null);
const [showReviewForm, setShowReviewForm] = useState(false);
const [formData, setFormData] = useState({
title: '',
content: '',
rating: 0
});
// Fetch reviews for this product
const { data: reviews, isLoading: reviewsLoading } = useProductReviews(productId);
// Check if user can submit a review
const { data: reviewPermission, isLoading: permissionLoading } = useCanReviewProduct(productId);
// Add review mutation
const addReview = useAddProductReview();
// Format date
const formatDate = (dateString) => {
if (!dateString) return '';
return format(new Date(dateString), 'MMMM d, yyyy');
};
// Handle form changes
const handleFormChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle rating change
const handleRatingChange = (event, newValue) => {
setFormData(prev => ({ ...prev, rating: newValue }));
};
// Handle submit review
const handleSubmitReview = (e) => {
e.preventDefault();
if (!formData.title) {
return; // Title is required
}
if (!replyTo && (!formData.rating || formData.rating < 1)) {
return; // Rating is required for top-level reviews
}
const reviewData = {
title: formData.title,
content: formData.content,
rating: replyTo ? undefined : formData.rating,
parentId: replyTo ? replyTo.id : undefined
};
addReview.mutate({
productId,
reviewData
}, {
onSuccess: () => {
// Reset form
setFormData({
title: '',
content: '',
rating: 0
});
setReplyTo(null);
setShowReviewForm(false);
}
});
};
// Render a single review
const renderReview = (review, isReply = false) => (
<Card
key={review.id}
variant={isReply ? "outlined" : "elevation"}
sx={{
mb: 2,
ml: isReply ? 4 : 0,
bgcolor: isReply ? 'background.paper' : undefined
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ mr: 1 }}>
{review.first_name ? review.first_name[0] : '?'}
</Avatar>
<Box>
<Typography variant="subtitle1">
{review.first_name} {review.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(review.created_at)}
</Typography>
</Box>
</Box>
{review.is_verified_purchase && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<VerifiedIcon color="primary" fontSize="small" sx={{ mr: 0.5 }} />
<Typography variant="caption" color="primary">
Verified Purchase
</Typography>
</Box>
)}
</Box>
{!isReply && review.rating && (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Rating value={review.rating} readOnly precision={0.5} />
<Typography variant="body2" sx={{ ml: 1 }}>
({review.rating}/5)
</Typography>
</Box>
)}
<Typography variant="h6" gutterBottom>{review.title}</Typography>
{review.content && (
<Typography variant="body2" paragraph>
{review.content}
</Typography>
)}
{isAuthenticated && (
<Button
size="small"
startIcon={<CommentIcon />}
onClick={() => {
setReplyTo(review);
setShowReviewForm(true);
// Scroll to form
document.getElementById('review-form')?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
>
Reply
</Button>
)}
</CardContent>
{/* Render replies */}
{review.replies && review.replies.length > 0 && (
<Box sx={{ px: 2, pb: 2 }}>
{review.replies.map(reply => renderReview(reply, true))}
</Box>
)}
</Card>
);
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h5" component="h2" gutterBottom>
Customer Reviews
{reviews && reviews.length > 0 && (
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
({reviews.length})
</Typography>
)}
</Typography>
<Divider sx={{ mb: 3 }} />
{/* Write a review button */}
{isAuthenticated && !permissionLoading && reviewPermission && (
<Box sx={{ mb: 3 }}>
{!showReviewForm ? (
<Button
variant="contained"
onClick={() => {
setReplyTo(null);
setShowReviewForm(true);
}}
disabled={!reviewPermission?.canReview}
>
Write a Review
</Button>
) : (
<Button
variant="outlined"
onClick={() => {
setReplyTo(null);
setShowReviewForm(false);
}}
>
Cancel
</Button>
)}
{!reviewPermission?.canReview && !reviewPermission?.isAdmin && (
<Alert severity="info" sx={{ mt: 2 }}>
{reviewPermission?.reason || 'You need to purchase this product before you can review it.'}
</Alert>
)}
</Box>
)}
{/* Review form */}
{showReviewForm && isAuthenticated && reviewPermission && (reviewPermission.canReview || replyTo) && (
<Paper id="review-form" sx={{ p: 3, mb: 4 }}>
<Typography variant="h6" gutterBottom>
{replyTo ? `Reply to ${replyTo.first_name}'s Review` : 'Write a Review'}
</Typography>
{replyTo && (
<Alert severity="info" sx={{ mb: 2 }}>
Replying to: "{replyTo.title}"
<Button
size="small"
onClick={() => setReplyTo(null)}
sx={{ ml: 2 }}
>
Cancel Reply
</Button>
</Alert>
)}
<form onSubmit={handleSubmitReview}>
<TextField
fullWidth
required
label="Review Title"
name="title"
value={formData.title}
onChange={handleFormChange}
margin="normal"
/>
{!replyTo && (
<Box sx={{ my: 2 }}>
<Typography component="legend">Rating *</Typography>
<Rating
name="rating"
value={formData.rating}
onChange={handleRatingChange}
precision={0.5}
size="large"
/>
{formData.rating === 0 && (
<Typography variant="caption" color="error">
Please select a rating
</Typography>
)}
</Box>
)}
<TextField
fullWidth
multiline
rows={4}
label="Review"
name="content"
value={formData.content}
onChange={handleFormChange}
margin="normal"
placeholder={replyTo ? "Write your reply..." : "Share your experience with this product..."}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="submit"
variant="contained"
disabled={
addReview.isLoading ||
!formData.title ||
(!replyTo && formData.rating < 1)
}
>
{addReview.isLoading ? (
<CircularProgress size={24} />
) : (
replyTo ? 'Post Reply' : 'Submit Review'
)}
</Button>
</Box>
</form>
</Paper>
)}
{/* Reviews list */}
{reviewsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : reviews && reviews.length > 0 ? (
<Box>
{reviews.map(review => renderReview(review))}
</Box>
) : (
<Alert severity="info">
This product doesn't have any reviews yet. Be the first to review it!
</Alert>
)}
</Box>
);
};
export default ProductReviews;

View file

@ -1,48 +0,0 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/reduxHooks';
import { CircularProgress, Box } from '@mui/material';
/**
* ProtectedRoute component to handle authenticated routes
*
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to render when authenticated
* @param {boolean} [props.requireAdmin=false] - Whether the route requires admin privileges
* @param {string} [props.redirectTo='/auth/login'] - Where to redirect if not authenticated
* @returns {ReactNode} The protected route
*/
const ProtectedRoute = ({
children,
requireAdmin = false,
redirectTo = '/auth/login'
}) => {
const { isAuthenticated, isAdmin, loading } = useAuth();
const location = useLocation();
// Show loading state
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
// Check authentication
if (!isAuthenticated) {
// Redirect to login, but save the current location so we can redirect back after login
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
// Check admin privileges if required
if (requireAdmin && !isAdmin) {
// Redirect to home if not admin
return <Navigate to="/" replace />;
}
// Render children if authenticated and has required privileges
return children;
};
export default ProtectedRoute;

View file

@ -1,103 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useLocation } from 'react-router-dom';
import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
/**
* SEOMetaTags - Component for managing SEO metadata
*
* This component handles all SEO-related meta tags including Open Graph,
* Twitter Cards, and structured data for better search engine visibility
*/
const SEOMetaTags = ({
title,
description,
keywords = [],
image,
canonical,
type = 'website',
structuredData = null,
noindex = false
}) => {
const location = useLocation();
const { data: brandingSettings } = useBrandingSettings();
// Default site settings from branding
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
const defaultDescription = brandingSettings?.site_description ||
'Your premier source for natural curiosities and unique specimens';
// Format the title with the site name
const formattedTitle = title ? `${title} | ${siteName}` : siteName;
// Use provided description or fall back to default
const metaDescription = description || defaultDescription;
// Base URL construction - important for absolute URLs
const baseUrl = typeof window !== 'undefined' ?
`${window.location.protocol}//${window.location.host}` :
'';
// Construct the canonical URL correctly
const canonicalUrl = canonical ?
(canonical.startsWith('http') ? canonical : `${baseUrl}${canonical}`) :
`${baseUrl}${location.pathname}`;
// Default image from branding settings
const defaultImage = brandingSettings?.logo_url || '/logo.png';
const metaImage = image || defaultImage;
const imageUrl = imageUtils.getImageUrl(metaImage);
return (
<Helmet>
{/* Basic meta tags */}
<title>{formattedTitle}</title>
<meta name="description" content={metaDescription} />
{/* Allow noindex pages */}
{noindex && <meta name="robots" content="noindex, nofollow" />}
{/* Keywords if provided */}
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
{/* Canonical link */}
<link rel="canonical" href={canonicalUrl} />
{/* Open Graph tags for social sharing */}
<meta property="og:title" content={formattedTitle} />
<meta property="og:description" content={metaDescription} />
<meta property="og:image" content={imageUrl} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content={type} />
<meta property="og:site_name" content={siteName} />
{/* Twitter Card tags */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={formattedTitle} />
<meta name="twitter:description" content={metaDescription} />
<meta name="twitter:image" content={imageUrl} />
{/* Structured data for search engines */}
{structuredData && (
<script type="application/ld+json">
{JSON.stringify(structuredData)}
</script>
)}
</Helmet>
);
};
SEOMetaTags.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
keywords: PropTypes.arrayOf(PropTypes.string),
image: PropTypes.string,
canonical: PropTypes.string,
type: PropTypes.string,
structuredData: PropTypes.object,
noindex: PropTypes.bool
};
export default SEOMetaTags;

View file

@ -1,98 +0,0 @@
import React, { useState } from 'react';
import {
PaymentElement,
useStripe as useStripeJs,
useElements
} from '@stripe/react-stripe-js';
import { Box, Button, CircularProgress, Alert, Typography } from '@mui/material';
import { useStripe } from '../context/StripeContext';
const StripePaymentForm = ({ orderId, onSuccess, onError }) => {
const stripe = useStripeJs();
const elements = useElements();
const { completeOrder } = useStripe();
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
if (!stripe || !elements) {
// Stripe.js hasn't loaded yet
return;
}
setIsLoading(true);
setMessage(null);
try {
// Submit the form
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/confirmation?order_id=${orderId}`,
},
redirect: 'if_required',
});
if (error) {
setMessage(error.message || 'An unexpected error occurred');
onError(error.message);
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
// Call our backend to update the order status
await completeOrder(orderId, paymentIntent.id);
setMessage('Payment successful!');
onSuccess(paymentIntent);
} else {
setMessage('Payment processing. Please wait for confirmation.');
}
} catch (err) {
console.error('Payment error:', err);
setMessage(err.message || 'An error occurred during payment processing');
onError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
{message && (
<Alert
severity={message.includes('successful') ? 'success' : 'error'}
sx={{ mb: 3 }}
>
{message}
</Alert>
)}
<Typography variant="h6" gutterBottom>
Enter your payment details
</Typography>
<Box sx={{ mb: 3 }}>
<PaymentElement />
</Box>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
size="large"
disabled={isLoading || !stripe || !elements}
>
{isLoading ? (
<>
<CircularProgress size={24} sx={{ mr: 1 }} />
Processing...
</>
) : (
'Pay Now'
)}
</Button>
</Box>
);
};
export default StripePaymentForm;

View file

@ -1,301 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Button,
FormControlLabel,
Checkbox,
Alert,
CircularProgress,
Paper,
Divider,
InputAdornment,
IconButton,
Tooltip,
Collapse
} from '@mui/material';
import {
SendOutlined as SendIcon,
InfoOutlined as InfoIcon,
CheckCircleOutline as CheckCircleIcon,
} from '@mui/icons-material';
import apiClient from '@services/api';
/**
* A reusable subscription form component for mailing lists
*
* @param {Object} props - Component props
* @param {string} props.listId - The ID of the mailing list to subscribe to
* @param {string} props.title - Form title
* @param {string} props.description - Form description
* @param {string} props.buttonText - Submit button text
* @param {string} props.successMessage - Message to show after successful submission
* @param {boolean} props.collectNames - Whether to collect first and last names
* @param {boolean} props.embedded - Whether this form is embedded in another component (styling)
* @param {function} props.onSuccess - Callback function after successful subscription
*/
const SubscriptionForm = ({
listId,
title = "Subscribe to Our Newsletter",
description = "Stay updated with our latest news and offers.",
buttonText = "Subscribe",
successMessage = "Thank you for subscribing! Please check your email to confirm your subscription.",
collectNames = true,
embedded = false,
onSuccess = null,
}) => {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
});
const [formErrors, setFormErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState(null);
const [showPrivacyInfo, setShowPrivacyInfo] = useState(false);
const [acceptedTerms, setAcceptedTerms] = useState(false);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Clear error when field is edited
if (formErrors[name]) {
setFormErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const validate = () => {
const errors = {};
// Validate email
if (!formData.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
errors.email = 'Email is invalid';
}
// Validate names if collectNames is true
if (collectNames) {
if (!formData.firstName) {
errors.firstName = 'First name is required';
}
}
// Validate terms acceptance
if (!acceptedTerms) {
errors.terms = 'You must accept the terms to subscribe';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
// Reset previous errors/success
setError(null);
// Validate form
if (!validate()) return;
setIsSubmitting(true);
try {
// Call the API to subscribe
await apiClient.post('/api/subscribers/subscribe', {
email: formData.email,
firstName: formData.firstName,
lastName: formData.lastName,
listId: listId
});
// Show success message and reset form
setIsSuccess(true);
setFormData({
firstName: '',
lastName: '',
email: '',
});
setAcceptedTerms(false);
// Call onSuccess callback if provided
if (onSuccess) onSuccess();
} catch (err) {
setError(err.response?.data?.message || 'An error occurred. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const containerStyles = embedded
? {}
: {
maxWidth: 500,
mx: 'auto',
my: 4,
p: 3,
borderRadius: 2,
boxShadow: 3
};
return (
<Paper
component="form"
onSubmit={handleSubmit}
sx={containerStyles}
elevation={embedded ? 0 : 1}
>
{/* Form Header */}
<Box sx={{ mb: 3, textAlign: embedded ? 'left' : 'center' }}>
<Typography variant={embedded ? "h6" : "h5"} component="h2" gutterBottom>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
)}
</Box>
{/* Success Message */}
{isSuccess && (
<Alert
icon={<CheckCircleIcon fontSize="inherit" />}
severity="success"
sx={{ mb: 3 }}
>
{successMessage}
</Alert>
)}
{/* Error Message */}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Form Fields */}
<Box sx={{ display: isSuccess ? 'none' : 'block' }}>
{collectNames && (
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<TextField
name="firstName"
label="First Name"
value={formData.firstName}
onChange={handleInputChange}
error={!!formErrors.firstName}
helperText={formErrors.firstName}
fullWidth
required
margin="normal"
size={embedded ? "small" : "medium"}
/>
<TextField
name="lastName"
label="Last Name"
value={formData.lastName}
onChange={handleInputChange}
error={!!formErrors.lastName}
helperText={formErrors.lastName}
fullWidth
margin="normal"
size={embedded ? "small" : "medium"}
/>
</Box>
)}
<TextField
name="email"
label="Email Address"
type="email"
value={formData.email}
onChange={handleInputChange}
error={!!formErrors.email}
helperText={formErrors.email}
fullWidth
required
margin="normal"
size={embedded ? "small" : "medium"}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Tooltip title="We'll send a confirmation email to verify your address.">
<IconButton edge="end" size="small" tabIndex={-1}>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
{/* Privacy Info & Terms Checkbox */}
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<FormControlLabel
control={
<Checkbox
checked={acceptedTerms}
onChange={(e) => setAcceptedTerms(e.target.checked)}
size={embedded ? "small" : "medium"}
/>
}
label="I agree to receive emails and accept the terms"
/>
<Tooltip title="Click for more information">
<IconButton
size="small"
onClick={() => setShowPrivacyInfo(!showPrivacyInfo)}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{formErrors.terms && (
<Typography variant="caption" color="error" sx={{ ml: 2 }}>
{formErrors.terms}
</Typography>
)}
<Collapse in={showPrivacyInfo}>
<Box sx={{ mt: 1, p: 2, bgcolor: 'background.paper', borderRadius: 1, fontSize: 'small' }}>
<Typography variant="body2" paragraph>
By subscribing, you agree to receive marketing emails from us. We respect your privacy and will never share your information with third parties.
</Typography>
<Typography variant="body2">
You can unsubscribe at any time by clicking the unsubscribe link in the footer of our emails. For information about our privacy practices, visit our website.
</Typography>
</Box>
</Collapse>
</Box>
{/* Submit Button */}
<Box sx={{ mt: 3, display: 'flex', justifyContent: embedded ? 'flex-start' : 'center' }}>
<Button
type="submit"
variant="contained"
color="primary"
disabled={isSubmitting}
startIcon={isSubmitting ? <CircularProgress size={20} /> : <SendIcon />}
>
{isSubmitting ? 'Subscribing...' : buttonText}
</Button>
</Box>
</Box>
</Paper>
);
};
export default SubscriptionForm;

View file

@ -1,21 +0,0 @@
const config = {
// App information
appName: import.meta.env.VITE_APP_NAME || 'Rocks, Bones & Sticks',
// API connection
apiUrl: import.meta.env.VITE_API_URL || '/api',
// Environment
environment: import.meta.env.VITE_ENVIRONMENT || 'beta',
isDevelopment: import.meta.env.DEV,
isProduction: import.meta.env.PROD,
// Site configuration (domain and protocol based on environment)
site: {
domain: import.meta.env.VITE_ENVIRONMENT === 'prod' ? import.meta.env.VITE_APP_PROD_URL : 'localhost:3000',
protocol: import.meta.env.VITE_ENVIRONMENT === 'prod' ? 'https' : 'http',
apiDomain: import.meta.env.VITE_ENVIRONMENT === 'prod' ? import.meta.env.VITE_API_PROD_URL : 'localhost:4000'
}
};
export default config;

View file

@ -1,179 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import config from '../config';
import apiClient from '@services/api';
import { useAuth } from '@hooks/reduxHooks';
// Create the context
const StripeContext = createContext();
export const StripeProvider = ({ children }) => {
const [stripePromise, setStripePromise] = useState(null);
const [clientSecret, setClientSecret] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const { isAuthenticated } = useAuth();
// Load or reload Stripe configuration when authentication state changes
useEffect(() => {
// Reset state when auth changes
setIsLoading(true);
setError(null);
// Try to load Stripe public key from environment
let publicKey = import.meta.env.VITE_STRIPE_PUBLIC_KEY;
// If not found, fetch from API
if (isAuthenticated) {
// Fetch Stripe public key from backend
apiClient.get('/payment/config')
.then(response => {
if (response.data.stripePublicKey) {
loadStripeInstance(response.data.stripePublicKey);
} else {
setError('Stripe public key not found');
setIsLoading(false);
}
})
.catch(err => {
console.error('Error fetching Stripe config:', err);
setError('Failed to load payment configuration');
setIsLoading(false);
});
} else if(publicKey){
loadStripeInstance(publicKey);
}
}, [isAuthenticated]); // Add isAuthenticated as a dependency to reload on auth changes
const loadStripeInstance = (publicKey) => {
try {
const stripe = loadStripe(publicKey);
setStripePromise(stripe);
setIsLoading(false);
} catch (err) {
console.error('Error loading Stripe:', err);
setError('Failed to initialize payment system');
setIsLoading(false);
}
};
// Create a checkout session
const createCheckoutSession = async (cartItems, orderId, shippingAddress, userId, shippingDetails = null) => {
try {
const response = await apiClient.post('/payment/create-checkout-session', {
cartItems,
orderId,
shippingAddress,
userId,
shippingDetails
});
return response.data;
} catch (err) {
console.error('Error creating checkout session:', err);
throw new Error(err.response?.data?.message || 'Failed to create checkout session');
}
};
// Check session status
const checkSessionStatus = async (sessionId) => {
try {
const response = await apiClient.get(`/payment/session-status/${sessionId}`);
return response.data;
} catch (err) {
console.error('Error checking session status:', err);
throw new Error(err.response?.data?.message || 'Failed to check payment status');
}
};
// Complete the order after successful payment
const completeOrder = async (orderId, sessionId, userId) => {
try {
const response = await apiClient.post('/cart/complete-checkout', {
orderId,
sessionId,
userId
});
return response.data;
} catch (err) {
console.error('Error completing order:', err);
throw new Error(err.response?.data?.message || 'Failed to complete order');
}
};
const value = {
stripePromise,
clientSecret,
isLoading,
error,
createCheckoutSession,
checkSessionStatus,
completeOrder,
reloadConfig: () => {
setIsLoading(true);
setError(null);
if (isAuthenticated){
apiClient.get('/payment/config')
.then(response => {
if (response.data.stripePublicKey) {
loadStripeInstance(response.data.stripePublicKey);
} else {
setError('Stripe public key not found');
setIsLoading(false);
}
})
.catch(err => {
console.error('Error fetching Stripe config:', err);
setError('Failed to load payment configuration');
setIsLoading(false);
});
}
}
};
return (
<StripeContext.Provider value={value}>
{children}
</StripeContext.Provider>
);
};
export const useStripe = () => {
const context = useContext(StripeContext);
if (!context) {
throw new Error('useStripe must be used within a StripeProvider');
}
return context;
};
// Wrapper component for Elements
export const StripeElementsProvider = ({ children, clientSecret }) => {
const { stripePromise } = useStripe();
const options = {
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#6200ea',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Roboto, sans-serif',
spacingUnit: '4px',
borderRadius: '4px'
}
}
};
if (!clientSecret) {
return children;
}
return (
<Elements stripe={stripePromise} options={options}>
{children}
</Elements>
);
};

View file

@ -1,82 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
user: JSON.parse(localStorage.getItem('user')) || null,
userData: JSON.parse(localStorage.getItem('userData')) || null,
apiKey: localStorage.getItem('apiKey') || null,
isAdmin: localStorage.getItem('isAdmin') === 'true',
isAuthenticated: !!localStorage.getItem('apiKey'),
loading: false,
error: null,
};
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginStart: (state) => {
state.loading = true;
state.error = null;
},
loginSuccess: (state, action) => {
state.loading = false;
state.isAuthenticated = true;
state.user = action.payload.user;
state.userData = {
id: action.payload.user,
apiKey: action.payload.apiKey,
email: action.payload.email,
firstName: action.payload.firstName,
lastName: action.payload.lastName,
isAdmin: action.payload.isAdmin,
isSuperAdmin: action.payload.isSuperAdmin
};
state.apiKey = action.payload.apiKey;
state.isAdmin = action.payload.isAdmin;
state.isSuperAdmin = action.payload.isSuperAdmin;
localStorage.setItem('apiKey', action.payload.apiKey);
localStorage.setItem('isAdmin', action.payload.isAdmin);
localStorage.setItem('isSuperAdmin', action.payload.isSuperAdmin);
localStorage.setItem('user', JSON.stringify(action.payload.user));
localStorage.setItem('userData', JSON.stringify({
id: action.payload.user,
apiKey: action.payload.apiKey,
isAdmin: action.payload.isAdmin,
isSuperAdmin: action.payload.isSuperAdmin
}));
},
loginFailed: (state, action) => {
state.loading = false;
state.error = action.payload;
},
logout: (state) => {
state.isAuthenticated = false;
state.user = null;
state.userData = null;
state.apiKey = null;
state.isAdmin = false;
state.isSuperAdmin = false;
localStorage.removeItem('apiKey');
localStorage.removeItem('isAdmin');
localStorage.removeItem('isSuperAdmin');
localStorage.removeItem('user');
localStorage.removeItem('userData');
},
clearError: (state) => {
state.error = null;
},
},
});
export const { loginStart, loginSuccess, loginFailed, logout, clearError } = authSlice.actions;
// Selectors
export const selectIsAuthenticated = (state) => state.auth.isAuthenticated;
export const selectIsAdmin = (state) => state.auth.isAdmin;
export const selectIsSuperAdmin = (state) => state.auth.isSuperAdmin;
export const selectCurrentUser = (state) => state.auth.user;
export const selectApiKey = (state) => state.auth.apiKey;
export const selectAuthLoading = (state) => state.auth.loading;
export const selectAuthError = (state) => state.auth.error;
export default authSlice.reducer;

View file

@ -1,70 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
id: null,
items: [],
itemCount: 0,
total: 0,
subtotal: 0,
couponCode: null,
couponDiscount: 0,
loading: false,
error: null,
};
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
cartLoading: (state) => {
state.loading = true;
state.error = null;
},
cartLoadingFailed: (state, action) => {
state.loading = false;
state.error = action.payload;
},
updateCart: (state, action) => {
state.loading = false;
state.id = action.payload.id;
state.items = action.payload.items;
state.itemCount = action.payload.itemCount;
state.total = action.payload.total;
state.subtotal = action.payload.subtotal || action.payload.total;
state.couponCode = action.payload.couponCode || null;
state.couponDiscount = action.payload.couponDiscount || 0;
},
clearCart: (state) => {
state.id = null;
state.items = [];
state.itemCount = 0;
state.total = 0;
state.subtotal = 0;
state.couponCode = null;
state.couponDiscount = 0;
},
clearCartError: (state) => {
state.error = null;
},
},
});
export const {
cartLoading,
cartLoadingFailed,
updateCart,
clearCart,
clearCartError
} = cartSlice.actions;
// Selectors
export const selectCartItems = (state) => state.cart.items;
export const selectCartItemCount = (state) => state.cart.itemCount;
export const selectCartTotal = (state) => state.cart.total;
export const selectCartSubtotal = (state) => state.cart.subtotal;
export const selectCouponCode = (state) => state.cart.couponCode;
export const selectCouponDiscount = (state) => state.cart.couponDiscount;
export const selectCartLoading = (state) => state.cart.loading;
export const selectCartError = (state) => state.cart.error;
export default cartSlice.reducer;

Some files were not shown because too many files have changed in this diff Show more