diff --git a/README.md b/README.md index 65feb0e..eb05b6d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,22 @@ -# zach -Web programming in .py and .Js +# Saku Grocery Management System + +Monorepo with `backend` (Express + MySQL) and `frontend` (React + Vite + Tailwind). + +## Backend +- Node 20, Express, MySQL via `mysql2` +- Routes: auth, products, orders, payments (mocked M-Pesa), feedback, admin, uploads (S3) +- Run: + 1. Copy `.env.example` to `.env` and fill values + 2. Install deps: `npm ci` inside `backend` + 3. Run migrations: `npm run migrate` + 4. Start: `npm run dev` + +## Frontend +- React 18 + Vite + Tailwind +- Run: + 1. `npm ci` inside `frontend` + 2. `npm run dev` + +## Notes +- Replace mocked M-Pesa flow with real Daraja integration +- Configure CORS and API base URL via `VITE_API_BASE` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..a12d3f3 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +PORT=8080 +CORS_ORIGIN=* +JWT_SECRET=replace_me + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD= +DB_NAME=saku_grocery + +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +S3_BUCKET= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1832871 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +EXPOSE 8080 +CMD ["npm", "start"] diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..9da1225 --- /dev/null +++ b/backend/db.js @@ -0,0 +1,26 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 3306), + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'saku_grocery', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, +}); + +export async function query(sql, params) { + const [rows] = await pool.execute(sql, params); + return rows; +} + +export async function getConnection() { + return pool.getConnection(); +} + +export default pool; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..80ec15f --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,25 @@ +import jwt from 'jsonwebtoken'; + +export function authenticate(req, res, next) { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Missing token' }); + try { + const payload = jwt.verify(token, process.env.JWT_SECRET || 'dev_secret'); + req.user = payload; + next(); + } catch (e) { + return res.status(401).json({ error: 'Invalid token' }); + } +} + +export function authorize(roles = []) { + const allowed = Array.isArray(roles) ? roles : [roles]; + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); + if (allowed.length && !allowed.includes(req.user.role)) { + return res.status(403).json({ error: 'Forbidden' }); + } + next(); + }; +} diff --git a/backend/middleware/errorHandler.js b/backend/middleware/errorHandler.js new file mode 100644 index 0000000..1817a98 --- /dev/null +++ b/backend/middleware/errorHandler.js @@ -0,0 +1,10 @@ +export function notFoundHandler(req, res, next) { + res.status(404).json({ error: 'Not Found' }); +} + +export function errorHandler(err, req, res, next) { + console.error(err); + const status = err.status || 500; + const message = err.message || 'Internal Server Error'; + res.status(status).json({ error: message }); +} diff --git a/backend/migrations.sql b/backend/migrations.sql new file mode 100644 index 0000000..c943785 --- /dev/null +++ b/backend/migrations.sql @@ -0,0 +1,66 @@ +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin','staff','customer') NOT NULL DEFAULT 'customer', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- Products table +CREATE TABLE IF NOT EXISTS products ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL, + stock INT NOT NULL DEFAULT 0, + image_url VARCHAR(1024), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +-- Orders table +CREATE TABLE IF NOT EXISTS orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + status ENUM('pending','paid','processing','shipped','delivered','cancelled') NOT NULL DEFAULT 'pending', + total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + mpesa_checkout_request_id VARCHAR(255), + mpesa_receipt_number VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB; + +-- Order items table +CREATE TABLE IF NOT EXISTS order_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + order_id INT NOT NULL, + product_id INT NOT NULL, + quantity INT NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) +) ENGINE=InnoDB; + +-- Feedback table +CREATE TABLE IF NOT EXISTS feedback ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT, + message TEXT NOT NULL, + rating INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB; + +-- Payments audit table +CREATE TABLE IF NOT EXISTS payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + order_id INT NOT NULL, + provider ENUM('mpesa') NOT NULL, + amount DECIMAL(10,2) NOT NULL, + status ENUM('initiated','success','failed') NOT NULL, + raw_payload JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (order_id) REFERENCES orders(id) +) ENGINE=InnoDB; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8a04899 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "saku-grocery-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "server.js", + "scripts": { + "dev": "node --watch server.js", + "start": "node server.js", + "migrate": "node scripts/run-migrations.js", + "test": "jest --runInBand" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.637.0", + "axios": "^1.7.4", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "date-fns": "^3.6.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-validator": "^7.2.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.9.7", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^6.3.3" + } +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..33095e5 --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,26 @@ +import express from 'express'; +import { subDays, format } from 'date-fns'; +import { authorize, authenticate } from '../middleware/auth.js'; +import { query } from '../db.js'; + +const router = express.Router(); + +router.get('/sales-summary', authenticate, authorize(['admin','staff']), async (req, res, next) => { + try { + const daily = await query( + `SELECT DATE(created_at) as d, SUM(total_amount) as total + FROM orders WHERE status IN ('paid','processing','shipped','delivered') + GROUP BY DATE(created_at) ORDER BY d DESC LIMIT 14` + ); + res.json({ daily }); + } catch (err) { next(err); } +}); + +router.get('/low-stock', authenticate, authorize(['admin','staff']), async (req, res, next) => { + try { + const rows = await query('SELECT * FROM products WHERE stock <= ? ORDER BY stock ASC LIMIT 50', [10]); + res.json(rows); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..a5df87c --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,49 @@ +import express from 'express'; +import { body, validationResult } from 'express-validator'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { query } from '../db.js'; + +const router = express.Router(); + +router.post('/register', + body('name').isString().isLength({ min: 2 }), + body('email').isEmail(), + body('password').isLength({ min: 6 }), + async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { name, email, password } = req.body; + try { + const existing = await query('SELECT id FROM users WHERE email=?', [email]); + if (existing.length) return res.status(409).json({ error: 'Email already registered' }); + const password_hash = await bcrypt.hash(password, 10); + const result = await query('INSERT INTO users (name, email, password_hash) VALUES (?,?,?)', [name, email, password_hash]); + const user = { id: result.insertId, name, email, role: 'customer' }; + const token = jwt.sign(user, process.env.JWT_SECRET || 'dev_secret', { expiresIn: '7d' }); + res.status(201).json({ user, token }); + } catch (err) { next(err); } + } +); + +router.post('/login', + body('email').isEmail(), + body('password').isString(), + async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { email, password } = req.body; + try { + const users = await query('SELECT id, name, email, password_hash, role FROM users WHERE email=?', [email]); + if (!users.length) return res.status(401).json({ error: 'Invalid credentials' }); + const userRow = users[0]; + const match = await bcrypt.compare(password, userRow.password_hash); + if (!match) return res.status(401).json({ error: 'Invalid credentials' }); + const user = { id: userRow.id, name: userRow.name, email: userRow.email, role: userRow.role }; + const token = jwt.sign(user, process.env.JWT_SECRET || 'dev_secret', { expiresIn: '7d' }); + res.json({ user, token }); + } catch (err) { next(err); } + } +); + +export default router; diff --git a/backend/routes/feedback.js b/backend/routes/feedback.js new file mode 100644 index 0000000..ae3644a --- /dev/null +++ b/backend/routes/feedback.js @@ -0,0 +1,25 @@ +import express from 'express'; +import { body, validationResult } from 'express-validator'; +import { authenticate } from '../middleware/auth.js'; +import { query } from '../db.js'; + +const router = express.Router(); + +router.post('/', authenticate, body('message').isString().isLength({ min: 5 }), body('rating').optional().isInt({ min: 1, max: 5 }), async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { message, rating } = req.body; + try { + await query('INSERT INTO feedback (user_id, message, rating) VALUES (?,?,?)', [req.user.id, message, rating || null]); + res.status(201).json({ ok: true }); + } catch (err) { next(err); } +}); + +router.get('/', authenticate, async (req, res, next) => { + try { + const rows = await query('SELECT f.*, u.name as user_name FROM feedback f LEFT JOIN users u ON u.id = f.user_id ORDER BY f.id DESC'); + res.json(rows); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/routes/orders.js b/backend/routes/orders.js new file mode 100644 index 0000000..243c63a --- /dev/null +++ b/backend/routes/orders.js @@ -0,0 +1,83 @@ +import express from 'express'; +import { body, param, validationResult } from 'express-validator'; +import { getConnection, query } from '../db.js'; +import { authenticate, authorize } from '../middleware/auth.js'; + +const router = express.Router(); + +router.post('/', + authenticate, + body('items').isArray({ min: 1 }), + body('items.*.product_id').isInt({ min: 1 }), + body('items.*.quantity').isInt({ min: 1 }), + body('total_amount').isFloat({ gt: 0 }), + async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { items, total_amount } = req.body; + const conn = await getConnection(); + try { + await conn.beginTransaction(); + const [orderResult] = await conn.query('INSERT INTO orders (user_id, total_amount) VALUES (?,?)', [req.user.id, total_amount]); + const orderId = orderResult.insertId; + + for (const item of items) { + const [rows] = await conn.query('SELECT price, stock FROM products WHERE id=? FOR UPDATE', [item.product_id]); + if (!rows.length) throw Object.assign(new Error('Product not found'), { status: 404 }); + const { price, stock } = rows[0]; + if (stock < item.quantity) throw Object.assign(new Error('Insufficient stock'), { status: 400 }); + await conn.query('UPDATE products SET stock = stock - ? WHERE id=?', [item.quantity, item.product_id]); + await conn.query('INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?,?,?,?)', [orderId, item.product_id, item.quantity, price]); + } + + await conn.commit(); + const order = await query('SELECT * FROM orders WHERE id=?', [orderId]); + const orderItems = await query('SELECT * FROM order_items WHERE order_id=?', [orderId]); + res.status(201).json({ order: order[0], items: orderItems }); + } catch (err) { + await conn.rollback(); + next(err); + } finally { + conn.release(); + } + } +); + +router.get('/', authenticate, async (req, res, next) => { + try { + const orders = await query('SELECT * FROM orders WHERE user_id=? ORDER BY id DESC', [req.user.id]); + res.json(orders); + } catch (err) { next(err); } +}); + +router.get('/all', authenticate, authorize(['admin','staff']), async (req, res, next) => { + try { + const orders = await query('SELECT * FROM orders ORDER BY id DESC'); + res.json(orders); + } catch (err) { next(err); } +}); + +router.get('/:id', authenticate, param('id').isInt(), async (req, res, next) => { + const id = parseInt(req.params.id, 10); + try { + const [order] = await query('SELECT * FROM orders WHERE id=?', [id]); + if (!order) return res.status(404).json({ error: 'Order not found' }); + if (order.user_id !== req.user.id && !['admin','staff'].includes(req.user.role)) { + return res.status(403).json({ error: 'Forbidden' }); + } + const items = await query('SELECT * FROM order_items WHERE order_id=?', [id]); + res.json({ order, items }); + } catch (err) { next(err); } +}); + +router.put('/:id/status', authenticate, authorize(['admin','staff']), param('id').isInt(), body('status').isIn(['pending','paid','processing','shipped','delivered','cancelled']), async (req, res, next) => { + const id = parseInt(req.params.id, 10); + const { status } = req.body; + try { + await query('UPDATE orders SET status=? WHERE id=?', [status, id]); + const [order] = await query('SELECT * FROM orders WHERE id=?', [id]); + res.json(order); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/routes/payments.js b/backend/routes/payments.js new file mode 100644 index 0000000..0340431 --- /dev/null +++ b/backend/routes/payments.js @@ -0,0 +1,44 @@ +import express from 'express'; +import { body, validationResult } from 'express-validator'; +import { authenticate } from '../middleware/auth.js'; +import { query } from '../db.js'; + +// NOTE: This is a mocked M-Pesa integration for development. +// Replace with actual Daraja API calls as needed. + +const router = express.Router(); + +router.post('/mpesa/stkpush', authenticate, body('order_id').isInt(), body('phone').isMobilePhone('any'), async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { order_id, phone } = req.body; + try { + const [order] = await query('SELECT * FROM orders WHERE id=? AND user_id=?', [order_id, req.user.id]); + if (!order) return res.status(404).json({ error: 'Order not found' }); + + const checkoutRequestId = `CHK_${Date.now()}`; + await query('UPDATE orders SET mpesa_checkout_request_id=? WHERE id=?', [checkoutRequestId, order_id]); + await query('INSERT INTO payments (order_id, provider, amount, status) VALUES (?,?,?,?)', [order_id, 'mpesa', order.total_amount, 'initiated']); + + // In real life, call Daraja STK push API and return response to client. + res.json({ ok: true, checkoutRequestId }); + } catch (err) { next(err); } +}); + +router.post('/mpesa/webhook', body('ResultCode').isInt(), async (req, res, next) => { + try { + const { ResultCode, CheckoutRequestID, MpesaReceiptNumber, Amount } = req.body; + const success = Number(ResultCode) === 0; + + const orders = await query('SELECT id FROM orders WHERE mpesa_checkout_request_id=?', [CheckoutRequestID]); + if (!orders.length) return res.status(404).json({ error: 'Order not found for webhook' }); + const orderId = orders[0].id; + + await query('UPDATE payments SET status=?, raw_payload=? WHERE order_id=?', [success ? 'success' : 'failed', JSON.stringify(req.body), orderId]); + await query('UPDATE orders SET status=?, mpesa_receipt_number=? WHERE id=?', [success ? 'paid' : 'pending', MpesaReceiptNumber || null, orderId]); + + res.json({ ok: true }); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/routes/products.js b/backend/routes/products.js new file mode 100644 index 0000000..b178aeb --- /dev/null +++ b/backend/routes/products.js @@ -0,0 +1,87 @@ +import express from 'express'; +import { body, param, query as vq, validationResult } from 'express-validator'; +import { query } from '../db.js'; +import { authenticate, authorize } from '../middleware/auth.js'; + +const router = express.Router(); + +router.get('/', + vq('page').optional().isInt({ min: 1 }), + vq('limit').optional().isInt({ min: 1, max: 100 }), + async (req, res, next) => { + const page = parseInt(req.query.page || '1', 10); + const limit = parseInt(req.query.limit || '12', 10); + const offset = (page - 1) * limit; + try { + const products = await query('SELECT * FROM products ORDER BY id DESC LIMIT ? OFFSET ?', [limit, offset]); + const [{ count }] = await query('SELECT COUNT(*) as count FROM products'); + res.json({ items: products, total: count, page, limit }); + } catch (err) { next(err); } + } +); + +router.get('/:id', param('id').isInt(), async (req, res, next) => { + const id = parseInt(req.params.id, 10); + try { + const rows = await query('SELECT * FROM products WHERE id=?', [id]); + if (!rows.length) return res.status(404).json({ error: 'Product not found' }); + res.json(rows[0]); + } catch (err) { next(err); } +}); + +router.post('/', + authenticate, + authorize(['admin','staff']), + body('name').isString().isLength({ min: 2 }), + body('price').isFloat({ gt: 0 }), + body('stock').isInt({ min: 0 }), + async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { name, description, price, stock, image_url } = req.body; + try { + const result = await query('INSERT INTO products (name, description, price, stock, image_url) VALUES (?,?,?,?,?)', [name, description || null, price, stock, image_url || null]); + const created = await query('SELECT * FROM products WHERE id=?', [result.insertId]); + res.status(201).json(created[0]); + } catch (err) { next(err); } + } +); + +router.put('/:id', + authenticate, + authorize(['admin','staff']), + param('id').isInt(), + body('name').optional().isString().isLength({ min: 2 }), + body('price').optional().isFloat({ gt: 0 }), + body('stock').optional().isInt({ min: 0 }), + async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const id = parseInt(req.params.id, 10); + const fields = ['name','description','price','stock','image_url']; + const updates = []; + const values = []; + for (const f of fields) { + if (req.body[f] !== undefined) { + updates.push(`${f} = ?`); + values.push(req.body[f]); + } + } + if (!updates.length) return res.status(400).json({ error: 'No fields to update' }); + try { + await query(`UPDATE products SET ${updates.join(', ')} WHERE id=?`, [...values, id]); + const updated = await query('SELECT * FROM products WHERE id=?', [id]); + res.json(updated[0]); + } catch (err) { next(err); } + } +); + +router.delete('/:id', authenticate, authorize(['admin','staff']), param('id').isInt(), async (req, res, next) => { + const id = parseInt(req.params.id, 10); + try { + await query('DELETE FROM products WHERE id=?', [id]); + res.status(204).send(); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/routes/uploads.js b/backend/routes/uploads.js new file mode 100644 index 0000000..33cb8aa --- /dev/null +++ b/backend/routes/uploads.js @@ -0,0 +1,41 @@ +import express from 'express'; +import multer from 'multer'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import crypto from 'crypto'; +import { authenticate, authorize } from '../middleware/auth.js'; + +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } }); + +const s3 = new S3Client({ + region: process.env.AWS_REGION, + credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY ? { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + } : undefined, +}); + +const router = express.Router(); + +router.post('/', authenticate, authorize(['admin','staff']), upload.single('file'), async (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const bucket = process.env.S3_BUCKET; + if (!bucket) return res.status(500).json({ error: 'S3 bucket not configured' }); + + const ext = req.file.originalname.split('.').pop(); + const key = `products/${crypto.randomUUID()}.${ext}`; + + const cmd = new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: req.file.buffer, + ContentType: req.file.mimetype, + ACL: 'public-read', + }); + await s3.send(cmd); + const url = `https://${bucket}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`; + res.status(201).json({ url }); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/scripts/run-migrations.js b/backend/scripts/run-migrations.js new file mode 100644 index 0000000..9b0daab --- /dev/null +++ b/backend/scripts/run-migrations.js @@ -0,0 +1,32 @@ +import fs from 'fs'; +import path from 'path'; +import url from 'url'; +import pool from '../db.js'; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function run() { + const filePath = path.join(__dirname, '..', 'migrations.sql'); + const sql = fs.readFileSync(filePath, 'utf-8'); + const statements = sql + .split(/;\s*\n/) + .map(s => s.trim()) + .filter(Boolean); + + const conn = await pool.getConnection(); + try { + for (const stmt of statements) { + await conn.query(stmt); + } + console.log('Migrations applied successfully'); + process.exit(0); + } catch (err) { + console.error('Migration failed:', err); + process.exit(1); + } finally { + conn.release(); + } +} + +run(); diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..a159b1a --- /dev/null +++ b/backend/server.js @@ -0,0 +1,52 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import dotenv from 'dotenv'; +import { errorHandler, notFoundHandler } from './middleware/errorHandler.js'; + +// Routes (defined later) +import authRoutes from './routes/auth.js'; +import productRoutes from './routes/products.js'; +import orderRoutes from './routes/orders.js'; +import feedbackRoutes from './routes/feedback.js'; +import adminRoutes from './routes/admin.js'; +import paymentRoutes from './routes/payments.js'; +import uploadRoutes from './routes/uploads.js'; + +dotenv.config(); + +const app = express(); + +app.use(helmet()); +app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') || '*', credentials: true })); +app.use(express.json({ limit: '2mb' })); +app.use(express.urlencoded({ extended: true })); +app.use(morgan('dev')); + +// Health +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// API routes +app.use('/api/auth', authRoutes); +app.use('/api/products', productRoutes); +app.use('/api/orders', orderRoutes); +app.use('/api/feedback', feedbackRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/payments', paymentRoutes); +app.use('/api/uploads', uploadRoutes); + +// 404 and error handlers +app.use(notFoundHandler); +app.use(errorHandler); + +const PORT = process.env.PORT || 8080; +if (process.env.NODE_ENV !== 'test') { + app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); + }); +} + +export default app; diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js new file mode 100644 index 0000000..8ade3a4 --- /dev/null +++ b/backend/tests/auth.test.js @@ -0,0 +1,24 @@ +import request from 'supertest' +import app from '../server.js' +import pool from '../db.js' + +beforeAll(async () => { + // Ensure schema exists for tests. Use test DB via env if set externally. +}) + +afterAll(async () => { + await pool.end?.() +}) + +describe('Auth', () => { + it('registers and logs in a user', async () => { + const email = `user${Date.now()}@test.com` + const res = await request(app).post('/api/auth/register').send({ name: 'Test', email, password: 'secret123' }) + expect(res.status).toBe(201) + expect(res.body.token).toBeTruthy() + + const login = await request(app).post('/api/auth/login').send({ email, password: 'secret123' }) + expect(login.status).toBe(200) + expect(login.body.token).toBeTruthy() + }) +}) diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ce4bfce --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0488035 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Saku Grocery + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2f02771 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "saku-grocery-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 5173" + }, + "dependencies": { + "axios": "^1.7.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.1" + }, + "devDependencies": { + "autoprefixer": "^10.4.19", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "vite": "^5.4.9" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..22f0240 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react' +import { Routes, Route, Link, useNavigate } from 'react-router-dom' +import Home from './pages/Home.jsx' +import Product from './pages/Product.jsx' +import Cart from './pages/Cart.jsx' +import Checkout from './pages/Checkout.jsx' +import Login from './pages/Login.jsx' +import Register from './pages/Register.jsx' +import AdminDashboard from './pages/AdminDashboard.jsx' +import MyOrders from './pages/MyOrders.jsx' +import OrderStatus from './pages/OrderStatus.jsx' + +function NavBar({ user, onLogout }) { + return ( + + ) +} + +export default function App() { + const [user, setUser] = useState(() => { + const json = localStorage.getItem('user') + return json ? JSON.parse(json) : null + }) + + useEffect(() => { + if (user) localStorage.setItem('user', JSON.stringify(user)) + else localStorage.removeItem('user') + }, [user]) + + const navigate = useNavigate() + const logout = () => { setUser(null); navigate('/') } + + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..c95fe21 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,22 @@ +import axios from 'axios' + +const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080/api' + +export const api = axios.create({ baseURL: API_BASE }) + +export function setAuthToken(token) { + if (token) { + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + } else { + delete api.defaults.headers.common['Authorization'] + } +} + +export async function uploadToS3(file) { + const form = new FormData() + form.append('file', file) + const { data } = await api.post('/uploads', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data.url +} diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx new file mode 100644 index 0000000..ea317de --- /dev/null +++ b/frontend/src/components/ProductCard.jsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Link } from 'react-router-dom' + +export default function ProductCard({ product, onAdd }) { + return ( +
+ {product.name} +

{product.name}

+

{product.description}

+
+ ${Number(product.price).toFixed(2)} + +
+ View +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a918d58 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,5 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { @apply bg-gray-50 text-gray-900; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..2bf504b --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App.jsx' +import './index.css' + +createRoot(document.getElementById('root')).render( + + + + + +) diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx new file mode 100644 index 0000000..65dc4de --- /dev/null +++ b/frontend/src/pages/AdminDashboard.jsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react' +import { api, uploadToS3 } from '../api' + +export default function AdminDashboard({ user }) { + const [name, setName] = useState('') + const [price, setPrice] = useState('') + const [stock, setStock] = useState('') + const [description, setDescription] = useState('') + const [file, setFile] = useState(null) + + const [sales, setSales] = useState([]) + const [lowStock, setLowStock] = useState([]) + + useEffect(() => { + if (!user?.token) return + api.defaults.headers.common['Authorization'] = `Bearer ${user.token}` + api.get('/admin/sales-summary').then(({ data }) => setSales(data.daily)) + api.get('/admin/low-stock').then(({ data }) => setLowStock(data)) + }, [user]) + + const createProduct = async (e) => { + e.preventDefault() + try { + let image_url + if (file) image_url = await uploadToS3(file) + const { data } = await api.post('/products', { name, price: Number(price), stock: Number(stock), description, image_url }) + alert(`Created product ${data.name}`) + setName(''); setPrice(''); setStock(''); setDescription(''); setFile(null) + } catch (e) { + alert(e.response?.data?.error || 'Create failed') + } + } + + if (!user) return
Login as admin/staff to access
+ + return ( +
+
+

Create Product

+ setName(e.target.value)} placeholder="Name" className="border p-2 rounded w-full" /> + setPrice(e.target.value)} placeholder="Price" className="border p-2 rounded w-full" /> + setStock(e.target.value)} placeholder="Stock" className="border p-2 rounded w-full" /> +