diff --git a/README.md b/README.md index 8e5fd19..bb4f015 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,11 @@ -# AWS -CLF-C02: AWS Certified Cloud Practitioner +Technology Stack +Frontend +Framework: React (Next.js recommended for SSR) +Styling: Tailwind CSS +Backend +Framework/Language: Node.js with Express.js +Database: PostgreSQL +Other Key Decisions +Payment Gateway: [To be decided - e.g., Stripe, PayPal] +Deployment Platform: [To be decided - e.g., Vercel, Heroku, AWS] +Version Control: Git (already in use) diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..bb8166d --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,23 @@ +const { Pool } = require('pg'); + +const pool = new Pool({ + user: process.env.DB_USER, + host: process.env.DB_HOST, + database: process.env.DB_DATABASE, + password: process.env.DB_PASSWORD, + port: process.env.DB_PORT, +}); + +pool.on('connect', () => { + console.log('Connected to the PostgreSQL database!'); +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + process.exit(-1); +}); + +module.exports = { + query: (text, params) => pool.query(text, params), + pool, // Export pool if direct access is needed +}; diff --git a/backend/src/config/index.js b/backend/src/config/index.js new file mode 100644 index 0000000..cb2349d --- /dev/null +++ b/backend/src/config/index.js @@ -0,0 +1,5 @@ +const dbConfig = require('./db'); + +module.exports = { + dbConfig +}; diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js new file mode 100644 index 0000000..255adca --- /dev/null +++ b/backend/src/controllers/authController.js @@ -0,0 +1,88 @@ +const User = require('../models/User'); +const jwt = require('jsonwebtoken'); + +// Basic input validation (can be expanded with a library like Joi) +const validateInput = (email, password) => { + if (!email || !password || password.length < 6) { + return 'Invalid input: Email is required and password must be at least 6 characters.'; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return 'Invalid input: Please provide a valid email address.'; + } + return null; // No error +}; + +exports.register = async (req, res) => { + try { + const { email, password, firstName, lastName, phoneNumber } = req.body; + + const validationError = validateInput(email, password); + if (validationError) { + return res.status(400).json({ message: validationError }); + } + + const existingUser = await User.findByEmail(email); + if (existingUser) { + return res.status(400).json({ message: 'User already exists with this email.' }); + } + + const newUser = await User.create(email, password, firstName, lastName, phoneNumber); + // Don't send password_hash back + const userResponse = { ...newUser }; + delete userResponse.password_hash; + + // Optionally, generate a JWT token upon registration + const token = jwt.sign({ id: newUser.id, email: newUser.email }, process.env.JWT_SECRET, { + expiresIn: process.env.JWT_EXPIRES_IN, + }); + + res.status(201).json({ + message: 'User registered successfully', + user: userResponse, + token // Send token if auto-login after registration + }); + + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ message: 'Server error during registration.' }); + } +}; + +exports.login = async (req, res) => { + try { + const { email, password } = req.body; + + const validationError = validateInput(email, password); + if (validationError) { + // More generic message for login to avoid confirming if email exists or not + return res.status(400).json({ message: 'Invalid email or password.' }); + } + + const user = await User.findByEmail(email); + if (!user) { + return res.status(401).json({ message: 'Invalid email or password.' }); // Generic message + } + + const isMatch = await User.comparePassword(password, user.password_hash); + if (!isMatch) { + return res.status(401).json({ message: 'Invalid email or password.' }); // Generic message + } + + const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, { + expiresIn: process.env.JWT_EXPIRES_IN, + }); + + // Don't send password_hash back + const userResponse = { id: user.id, email: user.email, first_name: user.first_name, last_name: user.last_name }; + + res.status(200).json({ + message: 'Login successful', + token, + user: userResponse + }); + + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ message: 'Server error during login.' }); + } +}; diff --git a/backend/src/controllers/categoryController.js b/backend/src/controllers/categoryController.js new file mode 100644 index 0000000..28be5ad --- /dev/null +++ b/backend/src/controllers/categoryController.js @@ -0,0 +1,60 @@ +const Category = require('../models/Category'); +const Product = require('../models/Product'); // To fetch products by category + +exports.getAllCategories = async (req, res) => { + try { + const categories = await Category.findAll(); + res.json(categories); + } catch (error) { + console.error('Get all categories error:', error); + res.status(500).json({ message: 'Server error while fetching categories.' }); + } +}; + +exports.getCategoryBySlug = async (req, res) => { + try { + const category = await Category.findBySlug(req.params.slug); + if (!category) { + return res.status(404).json({ message: 'Category not found.' }); + } + res.json(category); + } catch (error) { + console.error(`Get category by slug ${req.params.slug} error:`, error); + res.status(500).json({ message: 'Server error while fetching category.' }); + } +}; + +exports.getProductsByCategorySlug = async (req, res) => { + try { + const { slug } = req.params; + const { sortBy, order, limit = 10, page = 1 } = req.query; + const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10); + + const category = await Category.findBySlug(slug); + if (!category) { + return res.status(404).json({ message: 'Category not found.' }); + } + + const { products, total } = await Product.findAll({ + category: slug, // Pass slug to findAll + sortBy, + order, + limit: parseInt(limit, 10), + offset + }); + + res.json({ + category, // Send category details along with products + data: products, + pagination: { + totalItems: total, + totalPages: Math.ceil(total / limit), + currentPage: parseInt(page, 10), + pageSize: parseInt(limit, 10) + } + }); + } catch (error) { + console.error(`Get products by category slug ${req.params.slug} error:`, error); + res.status(500).json({ message: 'Server error while fetching products for category.' }); + } +}; diff --git a/backend/src/controllers/prescriptionController.js b/backend/src/controllers/prescriptionController.js new file mode 100644 index 0000000..e2309b5 --- /dev/null +++ b/backend/src/controllers/prescriptionController.js @@ -0,0 +1,90 @@ +const Prescription = require('../models/Prescription'); + +// Basic validation for prescription data (can be significantly expanded) +const validatePrescriptionData = (data) => { + if (!data.patientName || !data.prescriptionDate || !data.sphereRight || !data.sphereLeft || !data.prescriptionType) { + return 'Missing required fields: patientName, prescriptionDate, sphereRight, sphereLeft, prescriptionType are mandatory.'; + } + // Add more specific validations for sphere, cyl, axis formats, PD ranges, etc. + return null; +}; + + +exports.createPrescription = async (req, res) => { + try { + const userId = req.user.id; // From protect middleware + const validationError = validatePrescriptionData(req.body); + if (validationError) { + return res.status(400).json({ message: validationError }); + } + + const prescription = await Prescription.create(userId, req.body); + res.status(201).json(prescription); + } catch (error) { + console.error('Create prescription error:', error); + res.status(500).json({ message: 'Server error while creating prescription.' }); + } +}; + +exports.getPrescriptions = async (req, res) => { + try { + const userId = req.user.id; + const prescriptions = await Prescription.findByUserId(userId); + res.json(prescriptions); + } catch (error) { + console.error('Get prescriptions error:', error); + res.status(500).json({ message: 'Server error while fetching prescriptions.' }); + } +}; + +exports.getPrescriptionById = async (req, res) => { + try { + const userId = req.user.id; + const { id } = req.params; + const prescription = await Prescription.findByIdAndUserId(id, userId); + if (!prescription) { + return res.status(404).json({ message: 'Prescription not found or not owned by user.' }); + } + res.json(prescription); + } catch (error) { + console.error('Get prescription by ID error:', error); + res.status(500).json({ message: 'Server error while fetching prescription.' }); + } +}; + +exports.updatePrescription = async (req, res) => { + try { + const userId = req.user.id; + const { id } = req.params; + + // Optional: Validate data before sending to model + // const validationError = validatePrescriptionData(req.body); + // if (validationError) { + // return res.status(400).json({ message: validationError }); + // } + + const updatedPrescription = await Prescription.update(id, userId, req.body); + if (!updatedPrescription) { + return res.status(404).json({ message: 'Prescription not found or not owned by user for update.' }); + } + res.json(updatedPrescription); + } catch (error) { + console.error('Update prescription error:', error); + res.status(500).json({ message: 'Server error while updating prescription.' }); + } +}; + +exports.deletePrescription = async (req, res) => { + try { + const userId = req.user.id; + const { id } = req.params; + const success = await Prescription.delete(id, userId); + if (!success) { + return res.status(404).json({ message: 'Prescription not found or not owned by user for deletion.' }); + } + res.status(204).send(); // No content + } catch (error) { + console.error('Delete prescription error:', error); + res.status(500).json({ message: 'Server error while deleting prescription.' }); + } +}; diff --git a/backend/src/controllers/productController.js b/backend/src/controllers/productController.js new file mode 100644 index 0000000..9bf66e9 --- /dev/null +++ b/backend/src/controllers/productController.js @@ -0,0 +1,42 @@ +const Product = require('../models/Product'); + +exports.getAllProducts = async (req, res) => { + try { + const { category, sortBy, order, limit = 10, page = 1 } = req.query; + const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10); + + const { products, total } = await Product.findAll({ + category, + sortBy, + order, + limit: parseInt(limit, 10), + offset + }); + + res.json({ + data: products, + pagination: { + totalItems: total, + totalPages: Math.ceil(total / limit), + currentPage: parseInt(page, 10), + pageSize: parseInt(limit, 10) + } + }); + } catch (error) { + console.error('Get all products error:', error); + res.status(500).json({ message: 'Server error while fetching products.' }); + } +}; + +exports.getProductById = async (req, res) => { + try { + const product = await Product.findById(req.params.id); + if (!product) { + return res.status(404).json({ message: 'Product not found.' }); + } + res.json(product); + } catch (error) { + console.error(`Get product by ID ${req.params.id} error:`, error); + res.status(500).json({ message: 'Server error while fetching product.' }); + } +}; diff --git a/backend/src/middleware/authMiddleware.js b/backend/src/middleware/authMiddleware.js new file mode 100644 index 0000000..004fc2f --- /dev/null +++ b/backend/src/middleware/authMiddleware.js @@ -0,0 +1,34 @@ +const jwt = require('jsonwebtoken'); +const User = require('../models/User'); // To ensure user exists, optional + +// Middleware to verify JWT and attach user to request +const protect = async (req, res, next) => { + let token; + + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { + try { + // Get token from header + token = req.headers.authorization.split(' ')[1]; + + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Get user from the token's ID + // You might choose to just trust the decoded.id and not hit the DB here for performance, + // or hit the DB to ensure the user still exists and hasn't been disabled/deleted. + // For this example, we'll just use the decoded ID. + req.user = { id: decoded.id, email: decoded.email }; // Attach minimal user info + + next(); + } catch (error) { + console.error('Token verification failed:', error); + res.status(401).json({ message: 'Not authorized, token failed' }); + } + } + + if (!token) { + res.status(401).json({ message: 'Not authorized, no token' }); + } +}; + +module.exports = { protect }; diff --git a/backend/src/models/Category.js b/backend/src/models/Category.js new file mode 100644 index 0000000..432c2c3 --- /dev/null +++ b/backend/src/models/Category.js @@ -0,0 +1,27 @@ +const db = require('../config/db'); + +const Category = { + async findAll() { + const queryText = 'SELECT id, name, slug, description, parent_id FROM ProductCategories ORDER BY name ASC'; + try { + const { rows } = await db.query(queryText); + return rows; + } catch (err) { + console.error('Error fetching all categories:', err); + throw err; + } + }, + + async findBySlug(slug) { + const queryText = 'SELECT id, name, slug, description, parent_id FROM ProductCategories WHERE slug = $1'; + try { + const { rows } = await db.query(queryText, [slug]); + return rows[0]; + } catch (err) { + console.error(`Error fetching category by slug ${slug}:`, err); + throw err; + } + } +}; + +module.exports = Category; diff --git a/backend/src/models/Prescription.js b/backend/src/models/Prescription.js new file mode 100644 index 0000000..e055fa2 --- /dev/null +++ b/backend/src/models/Prescription.js @@ -0,0 +1,88 @@ +const db = require('../config/db'); + +// Helper to convert object keys from camelCase to snake_case for DB insertion +const camelToSnakeCase = str => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + +const Prescription = { + async create(userId, prescriptionData) { + const fields = Object.keys(prescriptionData); + const snakeCaseFields = fields.map(camelToSnakeCase); + const valuePlaceholders = fields.map((_, index) => `$${index + 2}`).join(', '); // $1 will be user_id + + const queryText = ` + INSERT INTO UserPrescriptions (user_id, ${snakeCaseFields.join(', ')}) + VALUES ($1, ${valuePlaceholders}) + RETURNING *; + `; + // Ensure order of values matches fields + const values = [userId, ...fields.map(field => prescriptionData[field])]; + + try { + const { rows } = await db.query(queryText, values); + return rows[0]; // Return the created prescription + } catch (err) { + console.error('Error creating prescription:', err); + throw err; + } + }, + + async findByUserId(userId) { + const queryText = 'SELECT * FROM UserPrescriptions WHERE user_id = $1 ORDER BY created_at DESC'; + try { + const { rows } = await db.query(queryText, [userId]); + return rows; + } catch (err) { + console.error('Error fetching prescriptions by user ID:', err); + throw err; + } + }, + + async findByIdAndUserId(id, userId) { + const queryText = 'SELECT * FROM UserPrescriptions WHERE id = $1 AND user_id = $2'; + try { + const { rows } = await db.query(queryText, [id, userId]); + return rows[0]; + } catch (err) { + console.error('Error fetching prescription by ID and user ID:', err); + throw err; + } + }, + + async update(id, userId, prescriptionData) { + const fields = Object.keys(prescriptionData); + if (fields.length === 0) { + return this.findByIdAndUserId(id, userId); // No fields to update, return current + } + const snakeCaseFields = fields.map(camelToSnakeCase); + const setClauses = snakeCaseFields.map((field, index) => `${field} = $${index + 3}`).join(', '); // $1=id, $2=userId + + const queryText = ` + UPDATE UserPrescriptions + SET ${setClauses}, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + RETURNING *; + `; + const values = [id, userId, ...fields.map(field => prescriptionData[field])]; + + try { + const { rows } = await db.query(queryText, values); + return rows[0]; + } catch (err) { + console.error('Error updating prescription:', err); + throw err; + } + }, + + async delete(id, userId) { + const queryText = 'DELETE FROM UserPrescriptions WHERE id = $1 AND user_id = $2 RETURNING id'; + try { + const { rows } = await db.query(queryText, [id, userId]); + return rows.length > 0; // Return true if deletion was successful + } catch (err) { + console.error('Error deleting prescription:', err); + throw err; + } + } +}; + +module.exports = Prescription; diff --git a/backend/src/models/Product.js b/backend/src/models/Product.js new file mode 100644 index 0000000..bff4c09 --- /dev/null +++ b/backend/src/models/Product.js @@ -0,0 +1,73 @@ +const db = require('../config/db'); + +const Product = { + async findAll({ category, sortBy = 'created_at', order = 'DESC', limit = 10, offset = 0 }) { + let queryText = ` + SELECT p.id, p.name, p.description, p.price, p.sku, p.stock_quantity, p.main_image_url, c.name as category_name, c.slug as category_slug + FROM Products p + LEFT JOIN ProductCategories c ON p.category_id = c.id -- Assuming you add category_id to Products + WHERE p.is_active = TRUE + `; + const queryParams = []; + + if (category) { + queryParams.push(category); + queryText += ` AND c.slug = $${queryParams.length}`; + } + + // Basic sorting - expand as needed + const validSortColumns = ['created_at', 'price', 'name']; + if (validSortColumns.includes(sortBy)) { + queryText += ` ORDER BY p.${sortBy} ${order.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'}`; + } else { + queryText += ` ORDER BY p.created_at DESC`; // Default sort + } + + queryParams.push(limit); + queryText += ` LIMIT $${queryParams.length}`; + queryParams.push(offset); + queryText += ` OFFSET $${queryParams.length}`; + + try { + const { rows } = await db.query(queryText, queryParams); + // Also fetch total count for pagination + let countQueryText = 'SELECT COUNT(*) FROM Products p WHERE p.is_active = TRUE'; + if (category) { + countQueryText = `SELECT COUNT(*) FROM Products p LEFT JOIN ProductCategories c ON p.category_id = c.id WHERE p.is_active = TRUE AND c.slug = $1`; + const { rows: countRows } = await db.query(countQueryText, [category]); + return { products: rows, total: parseInt(countRows[0].count, 10) }; + } else { + const { rows: countRows } = await db.query(countQueryText); + return { products: rows, total: parseInt(countRows[0].count, 10) }; + } + } catch (err) { + console.error('Error fetching all products:', err); + throw err; + } + }, + + async findById(id) { + const productQuery = ` + SELECT p.*, c.name as category_name, c.slug as category_slug + FROM Products p + LEFT JOIN ProductCategories c ON p.category_id = c.id + WHERE p.id = $1 AND p.is_active = TRUE + `; + const variantsQuery = 'SELECT * FROM ProductVariants WHERE product_id = $1'; + try { + const { rows: productRows } = await db.query(productQuery, [id]); + if (productRows.length === 0) { + return null; + } + const product = productRows[0]; + const { rows: variantRows } = await db.query(variantsQuery, [id]); + product.variants = variantRows; + return product; + } catch (err) { + console.error(`Error fetching product by ID ${id}:`, err); + throw err; + } + } +}; + +module.exports = Product; diff --git a/backend/src/models/User.js b/backend/src/models/User.js new file mode 100644 index 0000000..c40a792 --- /dev/null +++ b/backend/src/models/User.js @@ -0,0 +1,38 @@ +const db = require('../config/db'); // Assuming db.js exports a query function or pool +const bcrypt = require('bcryptjs'); + +const User = { + async create(email, password, firstName, lastName, phoneNumber) { + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const queryText = + 'INSERT INTO Users (email, password_hash, first_name, last_name, phone_number) VALUES ($1, $2, $3, $4, $5) RETURNING id, email, first_name, last_name, created_at'; + const values = [email, hashedPassword, firstName, lastName, phoneNumber]; + + try { + const { rows } = await db.query(queryText, values); + return rows[0]; + } catch (err) { + console.error('Error creating user:', err); + throw err; // Or handle more gracefully + } + }, + + async findByEmail(email) { + const queryText = 'SELECT * FROM Users WHERE email = $1'; + try { + const { rows } = await db.query(queryText, [email]); + return rows[0]; + } catch (err) { + console.error('Error finding user by email:', err); + throw err; + } + }, + + async comparePassword(candidatePassword, hashedPassword) { + return bcrypt.compare(candidatePassword, hashedPassword); + } +}; + +module.exports = User; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js new file mode 100644 index 0000000..8772cd1 --- /dev/null +++ b/backend/src/routes/authRoutes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const authController = require('../controllers/authController'); + +// @route POST api/auth/register +// @desc Register a new user +// @access Public +router.post('/register', authController.register); + +// @route POST api/auth/login +// @desc Authenticate user & get token +// @access Public +router.post('/login', authController.login); + +module.exports = router; diff --git a/backend/src/routes/categoryRoutes.js b/backend/src/routes/categoryRoutes.js new file mode 100644 index 0000000..79a1f77 --- /dev/null +++ b/backend/src/routes/categoryRoutes.js @@ -0,0 +1,20 @@ +const express = require('express'); +const router = express.Router(); +const categoryController = require('../controllers/categoryController'); + +// @route GET api/categories +// @desc Get all categories +// @access Public +router.get('/', categoryController.getAllCategories); + +// @route GET api/categories/:slug +// @desc Get a single category by slug +// @access Public +router.get('/:slug', categoryController.getCategoryBySlug); + +// @route GET api/categories/:slug/products +// @desc Get products for a specific category slug +// @access Public +router.get('/:slug/products', categoryController.getProductsByCategorySlug); + +module.exports = router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js new file mode 100644 index 0000000..b30d309 --- /dev/null +++ b/backend/src/routes/index.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); + +const authRoutes = require('./authRoutes'); +const productRoutes = require('./productRoutes'); +const categoryRoutes = require('./categoryRoutes'); +const prescriptionRoutes = require('./prescriptionRoutes'); // Add this + +router.use('/auth', authRoutes); +router.use('/products', productRoutes); +router.use('/categories', categoryRoutes); +router.use('/prescriptions', prescriptionRoutes); // Add this (mount under /api/prescriptions) + + +router.get('/', (req, res) => { + res.json({ message: 'Welcome to OptiCart API - Now with Prescriptions!' }); +}); + +module.exports = router; diff --git a/backend/src/routes/prescriptionRoutes.js b/backend/src/routes/prescriptionRoutes.js new file mode 100644 index 0000000..3dc34d2 --- /dev/null +++ b/backend/src/routes/prescriptionRoutes.js @@ -0,0 +1,34 @@ +const express = require('express'); +const router = express.Router(); +const prescriptionController = require('../controllers/prescriptionController'); +const { protect } = require('../middleware/authMiddleware'); // Assuming this is where 'protect' is + +// All routes in this file are protected and require authentication +router.use(protect); + +// @route POST api/prescriptions +// @desc Create a new prescription for the logged-in user +// @access Private +router.post('/', prescriptionController.createPrescription); + +// @route GET api/prescriptions +// @desc Get all prescriptions for the logged-in user +// @access Private +router.get('/', prescriptionController.getPrescriptions); + +// @route GET api/prescriptions/:id +// @desc Get a specific prescription by ID for the logged-in user +// @access Private +router.get('/:id', prescriptionController.getPrescriptionById); + +// @route PUT api/prescriptions/:id +// @desc Update a specific prescription by ID for the logged-in user +// @access Private +router.put('/:id', prescriptionController.updatePrescription); + +// @route DELETE api/prescriptions/:id +// @desc Delete a specific prescription by ID for the logged-in user +// @access Private +router.delete('/:id', prescriptionController.deletePrescription); + +module.exports = router; diff --git a/backend/src/routes/productRoutes.js b/backend/src/routes/productRoutes.js new file mode 100644 index 0000000..a53dff1 --- /dev/null +++ b/backend/src/routes/productRoutes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const productController = require('../controllers/productController'); + +// @route GET api/products +// @desc Get all products (with optional filters: category, sortBy, order, limit, page) +// @access Public +router.get('/', productController.getAllProducts); + +// @route GET api/products/:id +// @desc Get a single product by ID +// @access Public +router.get('/:id', productController.getProductById); + +module.exports = router; diff --git a/docs/AccessibilityGuidelines.md b/docs/AccessibilityGuidelines.md new file mode 100644 index 0000000..1982a4d --- /dev/null +++ b/docs/AccessibilityGuidelines.md @@ -0,0 +1,83 @@ +# Accessibility (A11y) Guidelines for OptiCart + +Accessibility is a core requirement for the OptiCart platform, ensuring that the website is usable by everyone, including people with disabilities. These guidelines should be followed throughout the design and development process. + +## 1. Semantic HTML + +* **Use HTML elements for their intended purpose.** For example: + * `