Compare commits
No commits in common. "f7694d6594a1776196a71015909f39ac5f52ddec" and "933c2deb158c01f4d7b7aef66ff1713990db7431" have entirely different histories.
f7694d6594
...
933c2deb15
181 changed files with 41757 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.env
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.DS_Store
|
||||
backend/public/uploads/*
|
||||
backend/public/*
|
||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.env
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.DS_Store
|
||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
RUN mkdir -p public/uploads/products
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
92
backend/README.md
Normal file
92
backend/README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# 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
|
||||
35
backend/package.json
Normal file
35
backend/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
204
backend/src/config.js
Normal file
204
backend/src/config.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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;
|
||||
73
backend/src/db/index.js
Normal file
73
backend/src/db/index.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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 };
|
||||
418
backend/src/index.js
Normal file
418
backend/src/index.js
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
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;
|
||||
65
backend/src/middleware/adminAuth.js
Normal file
65
backend/src/middleware/adminAuth.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* 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'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
49
backend/src/middleware/auth.js
Normal file
49
backend/src/middleware/auth.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* 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'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
19
backend/src/middleware/seoMiddleware.js
Normal file
19
backend/src/middleware/seoMiddleware.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* 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;
|
||||
67
backend/src/middleware/upload.js
Normal file
67
backend/src/middleware/upload.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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;
|
||||
181
backend/src/models/SystemSettings.js
Normal file
181
backend/src/models/SystemSettings.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
321
backend/src/routes/auth.js
Normal file
321
backend/src/routes/auth.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
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;
|
||||
};
|
||||
282
backend/src/routes/blog.js
Normal file
282
backend/src/routes/blog.js
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
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;
|
||||
};
|
||||
494
backend/src/routes/blogAdmin.js
Normal file
494
backend/src/routes/blogAdmin.js
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
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;
|
||||
};
|
||||
208
backend/src/routes/blogCommentsAdmin.js
Normal file
208
backend/src/routes/blogCommentsAdmin.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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;
|
||||
};
|
||||
1286
backend/src/routes/cart.js
Normal file
1286
backend/src/routes/cart.js
Normal file
File diff suppressed because it is too large
Load diff
186
backend/src/routes/categoryAdmin.js
Normal file
186
backend/src/routes/categoryAdmin.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
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;
|
||||
};
|
||||
689
backend/src/routes/couponAdmin.js
Normal file
689
backend/src/routes/couponAdmin.js
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
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;
|
||||
};
|
||||
1063
backend/src/routes/emailCampaignsAdmin.js
Normal file
1063
backend/src/routes/emailCampaignsAdmin.js
Normal file
File diff suppressed because it is too large
Load diff
602
backend/src/routes/emailTemplatesAdmin.js
Normal file
602
backend/src/routes/emailTemplatesAdmin.js
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
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;
|
||||
};
|
||||
136
backend/src/routes/emailTracking.js
Normal file
136
backend/src/routes/emailTracking.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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;
|
||||
};
|
||||
180
backend/src/routes/images.js
Normal file
180
backend/src/routes/images.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
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;
|
||||
};
|
||||
724
backend/src/routes/mailingListAdmin.js
Normal file
724
backend/src/routes/mailingListAdmin.js
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
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;
|
||||
};
|
||||
599
backend/src/routes/orderAdmin.js
Normal file
599
backend/src/routes/orderAdmin.js
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
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;
|
||||
};
|
||||
597
backend/src/routes/productAdmin.js
Normal file
597
backend/src/routes/productAdmin.js
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
41
backend/src/routes/productAdminImages.js
Normal file
41
backend/src/routes/productAdminImages.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
246
backend/src/routes/productReviews.js
Normal file
246
backend/src/routes/productReviews.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
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;
|
||||
};
|
||||
208
backend/src/routes/productReviewsAdmin.js
Normal file
208
backend/src/routes/productReviewsAdmin.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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;
|
||||
};
|
||||
220
backend/src/routes/products.js
Normal file
220
backend/src/routes/products.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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;
|
||||
};
|
||||
52
backend/src/routes/publicSettings.js
Normal file
52
backend/src/routes/publicSettings.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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;
|
||||
};
|
||||
395
backend/src/routes/settingsAdmin.js
Normal file
395
backend/src/routes/settingsAdmin.js
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
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;
|
||||
};
|
||||
179
backend/src/routes/shipping.js
Normal file
179
backend/src/routes/shipping.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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;
|
||||
}
|
||||
|
||||
259
backend/src/routes/stripePayment.js
Normal file
259
backend/src/routes/stripePayment.js
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
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;
|
||||
};
|
||||
407
backend/src/routes/subscribers.js
Normal file
407
backend/src/routes/subscribers.js
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
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;
|
||||
};
|
||||
579
backend/src/routes/subscribersAdmin.js
Normal file
579
backend/src/routes/subscribersAdmin.js
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
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;
|
||||
};
|
||||
248
backend/src/routes/userAdmin.js
Normal file
248
backend/src/routes/userAdmin.js
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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;
|
||||
};
|
||||
103
backend/src/routes/userOrders.js
Normal file
103
backend/src/routes/userOrders.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// 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;
|
||||
};
|
||||
58
backend/src/services/cacheService.js
Normal file
58
backend/src/services/cacheService.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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;
|
||||
630
backend/src/services/emailService.js
Normal file
630
backend/src/services/emailService.js
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
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;
|
||||
115
backend/src/services/notificationService.js
Normal file
115
backend/src/services/notificationService.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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;
|
||||
54
backend/src/services/queueService.js
Normal file
54
backend/src/services/queueService.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// 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;
|
||||
348
backend/src/services/shippingService.js
Normal file
348
backend/src/services/shippingService.js
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
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;
|
||||
124
backend/src/services/sitemapService.js
Normal file
124
backend/src/services/sitemapService.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
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;
|
||||
83
backend/src/services/storageService.js
Normal file
83
backend/src/services/storageService.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// 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;
|
||||
93
backend/src/worker.js
Normal file
93
backend/src/worker.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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);
|
||||
});
|
||||
170
db/init/01-schema.sql
Normal file
170
db/init/01-schema.sql
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
-- 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();
|
||||
185
db/init/02-seed.sql
Normal file
185
db/init/02-seed.sql
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
-- 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';
|
||||
5
db/init/03-api-key.sql
Normal file
5
db/init/03-api-key.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- 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);
|
||||
16
db/init/04-product-images.sql
Normal file
16
db/init/04-product-images.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- 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;
|
||||
10
db/init/05-admin-role.sql
Normal file
10
db/init/05-admin-role.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- 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';
|
||||
1
db/init/06-product-categories.sql
Normal file
1
db/init/06-product-categories.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE product_categories ADD COLUMN image_path VARCHAR(255);
|
||||
2
db/init/07-user-keys.sql
Normal file
2
db/init/07-user-keys.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_disabled BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS internal_notes TEXT;
|
||||
9
db/init/08-create-email.sql
Normal file
9
db/init/08-create-email.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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'
|
||||
);
|
||||
69
db/init/09-system-settings.sql
Normal file
69
db/init/09-system-settings.sql
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
-- 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;
|
||||
22
db/init/10-payment.sql
Normal file
22
db/init/10-payment.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- 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;
|
||||
16
db/init/11-notifications.sql
Normal file
16
db/init/11-notifications.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- 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);
|
||||
10
db/init/12-shipping-orders.sql
Normal file
10
db/init/12-shipping-orders.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- 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;
|
||||
1
db/init/13-cart-metadata.sql
Normal file
1
db/init/13-cart-metadata.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE carts ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb;
|
||||
53
db/init/14-product-notifications.sql
Normal file
53
db/init/14-product-notifications.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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;
|
||||
65
db/init/15-coupon.sql
Normal file
65
db/init/15-coupon.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
-- 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();
|
||||
84
db/init/16-blog-schema.sql
Normal file
84
db/init/16-blog-schema.sql
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
-- 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');
|
||||
80
db/init/17-product-reviews.sql
Normal file
80
db/init/17-product-reviews.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- 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();
|
||||
61
db/init/18-email-templates.sql
Normal file
61
db/init/18-email-templates.sql
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
-- 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>© 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>© 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>© 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>© 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>© 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;
|
||||
49
db/init/19-branding-settings.sql
Normal file
49
db/init/19-branding-settings.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- 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;
|
||||
133
db/init/20-maillinglist.sql
Normal file
133
db/init/20-maillinglist.sql
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
-- 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');
|
||||
4
db/init/21-order-refund.sql
Normal file
4
db/init/21-order-refund.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- 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;
|
||||
1
db/init/22-blog-json.sql
Normal file
1
db/init/22-blog-json.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS design JSONB;
|
||||
9
db/init/23-suparadmin.sql
Normal file
9
db/init/23-suparadmin.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- 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);
|
||||
67
db/test/test-api.sh
Normal file
67
db/test/test-api.sh
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#!/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!"
|
||||
111
docker-compose.yml
Normal file
111
docker-compose.yml
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
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
|
||||
236
fileStructure.txt
Normal file
236
fileStructure.txt
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/ (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
|
||||
58
frontend/Dockerfile
Normal file
58
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# 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"]
|
||||
66
frontend/README.md
Normal file
66
frontend/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# 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
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!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>
|
||||
37
frontend/nginx.conf
Normal file
37
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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]\.";
|
||||
}
|
||||
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 392 B |
33
frontend/setup-frontend.sh
Normal file
33
frontend/setup-frontend.sh
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#!/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!"
|
||||
237
frontend/src/App.jsx
Normal file
237
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
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;
|
||||
193
frontend/src/components/CookieConsentPopup.jsx
Normal file
193
frontend/src/components/CookieConsentPopup.jsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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;
|
||||
166
frontend/src/components/CookieSettingsButton.jsx
Normal file
166
frontend/src/components/CookieSettingsButton.jsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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;
|
||||
138
frontend/src/components/CouponInput.jsx
Normal file
138
frontend/src/components/CouponInput.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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;
|
||||
150
frontend/src/components/EmailDialog.jsx
Normal file
150
frontend/src/components/EmailDialog.jsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
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;
|
||||
116
frontend/src/components/Footer.jsx
Normal file
116
frontend/src/components/Footer.jsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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;
|
||||
225
frontend/src/components/ImageUploader.jsx
Normal file
225
frontend/src/components/ImageUploader.jsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
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;
|
||||
39
frontend/src/components/Notifications.jsx
Normal file
39
frontend/src/components/Notifications.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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;
|
||||
361
frontend/src/components/OrderStatusDialog.jsx
Normal file
361
frontend/src/components/OrderStatusDialog.jsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
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;
|
||||
67
frontend/src/components/ProductImage.jsx
Normal file
67
frontend/src/components/ProductImage.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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;
|
||||
27
frontend/src/components/ProductRatingDisplay.jsx
Normal file
27
frontend/src/components/ProductRatingDisplay.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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;
|
||||
328
frontend/src/components/ProductReviews.jsx
Normal file
328
frontend/src/components/ProductReviews.jsx
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
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;
|
||||
48
frontend/src/components/ProtectedRoute.jsx
Normal file
48
frontend/src/components/ProtectedRoute.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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;
|
||||
103
frontend/src/components/SEOMetaTags.jsx
Normal file
103
frontend/src/components/SEOMetaTags.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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;
|
||||
98
frontend/src/components/StripePaymentForm.jsx
Normal file
98
frontend/src/components/StripePaymentForm.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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;
|
||||
301
frontend/src/components/SubscriptionForm.jsx
Normal file
301
frontend/src/components/SubscriptionForm.jsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
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;
|
||||
21
frontend/src/config.js
Normal file
21
frontend/src/config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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;
|
||||
179
frontend/src/context/StripeContext.jsx
Normal file
179
frontend/src/context/StripeContext.jsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
82
frontend/src/features/auth/authSlice.js
Normal file
82
frontend/src/features/auth/authSlice.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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;
|
||||
70
frontend/src/features/cart/cartSlice.js
Normal file
70
frontend/src/features/cart/cartSlice.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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
Loading…
Reference in a new issue