Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`
14 changes: 14 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -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=
7 changes: 7 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 8080
CMD ["npm", "start"]
26 changes: 26 additions & 0 deletions backend/db.js
Original file line number Diff line number Diff line change
@@ -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;
25 changes: 25 additions & 0 deletions backend/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -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();
};
}
10 changes: 10 additions & 0 deletions backend/middleware/errorHandler.js
Original file line number Diff line number Diff line change
@@ -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 });
}
66 changes: 66 additions & 0 deletions backend/migrations.sql
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 32 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
26 changes: 26 additions & 0 deletions backend/routes/admin.js
Original file line number Diff line number Diff line change
@@ -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;
49 changes: 49 additions & 0 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
@@ -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;
25 changes: 25 additions & 0 deletions backend/routes/feedback.js
Original file line number Diff line number Diff line change
@@ -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;
83 changes: 83 additions & 0 deletions backend/routes/orders.js
Original file line number Diff line number Diff line change
@@ -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;
Loading