diff --git a/README.md b/README.md index 5617078..f7bb7c5 100644 --- a/README.md +++ b/README.md @@ -16,55 +16,86 @@ A full-stack web application for mapping and searching family locations in Yemen - π **Family Search**: Search for families by name with autocomplete suggestions - πΊοΈ **Interactive Map**: Leaflet-based map with multiple tile layer options - π **Location Markers**: View family locations with city information -- π¨ **Modern UI**: Clean and responsive design +- β **Add Families**: Admin UI to add new family locations to the database +- πΎ **MongoDB Database**: Real database persistence with full CRUD operations +- π¨ **Modern UI**: Clean and responsive design with modal forms - π³ **Docker Ready**: Containerized for easy deployment +- βΈοΈ **Kubernetes Ready**: Helm chart for production deployment - π **Health Checks**: Built-in health monitoring ## π οΈ Tech Stack **Backend:** -- Node.js -- Express.js -- JSON data storage +- Node.js 20 Alpine +- Express.js 4.18 +- MongoDB 7.0 +- Mongoose ODM +- CORS enabled **Frontend:** - HTML5/CSS3 -- JavaScript (ES6+) +- Vanilla JavaScript (ES6+) - Leaflet.js (interactive maps) - Fuse.js (fuzzy search) +- Nginx (production server) **DevOps:** -- Docker -- Docker Compose +- Docker & Docker Compose +- Kubernetes & Helm Charts +- Woodpecker CI/CD +- Harbor Registry -## π Project Structure - -``` -oramap/ -βββ backend/ -β βββ server.js # Express server +## π Project Structure with MongoDB β βββ package.json # Backend dependencies β βββ Dockerfile # Backend container image -β βββ data/ -β βββ families.json # Family location data +β βββ config/ +β β βββ database.js # MongoDB connection +β βββ models/ +β β βββ Family.js # Mongoose schema +β βββ scripts/ +β βββ seed.js # Database seeding script βββ frontend/ β βββ Dockerfile # Frontend Nginx container β βββ nginx.conf # Nginx configuration β βββ public/ -β βββ index.html # Frontend HTML -β βββ script.js # Frontend JavaScript -β βββ style.css # Styles -βββ public/ -β βββ index.html # Shared frontend files +β βββ index.html # Frontend HTML with modal form +β βββ script.js # Frontend JavaScript with CRUD +β βββ style.css # Styles including modal +βββ oramap/ # Helm Chart +β βββ Chart.yaml # Chart metadata (v0.3.0) +β βββ values.yaml # Configuration values +β βββ README.md # Helm documentation +β βββ templates/ +β βββ deployment.yaml # Backend & Frontend deployments +β βββ service.yaml # Services +β βββ ingress.yaml # Ingress rules +β βββ configmap.yaml # Nginx config +β βββ mongodb-statefulset.yaml # MongoDB StatefulSet +β βββ mongodb-service.yaml # MongoDB service +βββ old/ # Legacy files +β βββ server.js +β βββ package.json +β βββ data/families.json +βββ Dockerfile # Monolith Docker build +βββ docker-compose.yml # Monolith deployment +βββ docker-compose.microservices.yml # Microservices with MongoDB β βββ script.js β βββ style.css βββ Dockerfile # Monolith Docker build βββ docker-compose.yml # Monolith deployment -βββ docker-compose.microservices.yml # Microservices deployment -βββ .woodpecker.yaml # CI/CD pipeline config -βββ .dockerignore # Docker ignore rules -``` +βββ MongoDB**: Database for family locations (Port 27017) +- **Backend**: Express API server with MongoDB integration (Internal Port 3000) +- **Frontend**: Nginx serving static files with API proxy (Port 80) +- Nginx proxies `/api/*` requests to backend +- Best for: Production, scalability, CI/CD pipelines +- Use: `docker-compose -f docker-compose.microservices.yml up` +### 3. **Kubernetes Mode** (Enterprise) +- Helm chart deployment with StatefulSet for MongoDB +- Persistent storage for database +- Horizontal scaling for backend/frontend +- Ingress with TLS support +- See: `oramap/README.md` for Helm chart documentation ## ποΈ Architecture The application supports two deployment modes: @@ -89,18 +120,33 @@ The application supports two deployment modes: ### Local Development -1. **Install backend dependencies:** +1. **Start MongoDB (optional - uses local instance if not running):** +```bash +# Using Docker +docker run -d -p 27017:27017 --name mongodb mongo:7.0 + +# Or install MongoDB locally +``` + +2. **Install backend dependencies:** ```bash cd backend npm install ``` -2. **Start the server:** +3. **Seed initial data:** ```bash -npm start +npm run seed ``` -3. **Open your browser:** +4. **Start the server:** +```bash +npm start +# Or for development with auto-reload: +npm run dev +``` + +5. **Open your browser:** Navigate to `http://localhost:3000` ## π³ Docker Deployment @@ -121,22 +167,30 @@ docker-compose down ### Microservices Mode (Recommended for Production) -1. **Build and start:** +1. **Build and start all services (Backend + Frontend + MongoDB):** ```bash docker-compose -f docker-compose.microservices.yml up -d ``` -2. **Access:** http://localhost (Port 80) +2. **Seed the database:** +```bash +docker exec -it oramap-backend npm run seed +``` -3. **View logs:** +3. **Access:** http://localhost (Port 80) + +4. **View logs:** ```bash docker logs oramap-frontend docker logs oramap-backend +docker logs oramap-mongo ``` -4. **Stop:** +5. **Stop:** ```bash docker-compose -f docker-compose.microservices.yml down +# To remove volumes: +docker-compose -f docker-compose.microservices.yml down -v ``` ### Using Docker directly @@ -184,36 +238,144 @@ cd backend npm run dev ``` +### Database Management + +**Seed initial data:** +```bash +npm run seed +``` + +**Force re-seed (clears existing data):** +```bash +npm run seed:force +``` + ### Adding New Families -Edit `backend/data/families.json` and add entries in the following format: +Use the web UI "Add Family" button, or make API calls: -```json -{ - "family": "Family Name (Hebrew)", - "city": "City Name (Hebrew)", - "lat": 15.3545, - "lng": 44.2064 -} +```bash +curl -X POST http://localhost:3000/api/families \ + -H "Content-Type: application/json" \ + -d '{ + "family": "Family Name", + "city": "City Name", + "lat": 15.3545, + "lng": 44.2064 + }' ``` ## π‘ API Endpoints +### Get All Families +``` +GET /api/families +``` +Returns all families sorted by name. + +**Example:** +```bash +curl http://localhost:3000/api/families +``` + +**Response:** +```json +[ + { + "_id": "507f1f77bcf86cd799439011", + "family": "ΧΧ©Χ€ΧΧͺ ΧΧ€Χ", + "city": "Χ¦Χ Χ’Χ", + "lat": 15.3545, + "lng": 44.2064, + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-01T00:00:00.000Z" + } +] +``` + ### Search Families ``` GET /api/search?family={familyName} ``` -Returns matching family records with location data. +Returns matching family records using MongoDB text search. **Example:** ```bash curl "http://localhost:3000/api/search?family=Kafe" ``` +### Create Family +``` +POST /api/families +Content-Type: application/json +``` +Creates a new family location. + +**Request Body:** +```json +{ + "family": "ΧΧ©Χ€ΧΧͺ ΧΧΧ", + "city": "Χ’ΧΧ", + "lat": 12.7855, + "lng": 45.0187 +} +``` + +**Response:** +```json +{ + "_id": "507f1f77bcf86cd799439011", + "family": "ΧΧ©Χ€ΧΧͺ ΧΧΧ", + "city": "Χ’ΧΧ", + "lat": 12.7855, + "lng": 45.0187, + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-01T00:00:00.000Z" +} +``` + +### Update Family +``` +PUT /api/families/:id +Content-Type: application/json +``` +Updates an existing family. + +**Request Body:** +```json +{ + "family": "ΧΧ©Χ€ΧΧͺ ΧΧΧ", + "city": "Χ¦Χ Χ’Χ", + "lat": 15.3695, + "lng": 44.1910 +} +``` + +### Delete Family +``` +DELETE /api/families/:id +``` +Deletes a family by ID. + +**Example:** +```bash +curl -X DELETE http://localhost:3000/api/families/507f1f77bcf86cd799439011 +``` + ### Health Check ``` GET /api/health ``` +Returns server and database health status. + +**Response:** +```json +{ + "status": "ok", + "timestamp": "2024-01-01T00:00:00.000Z", + "database": "connected" +} +``` Returns server health status. **Example Response:** diff --git a/backend/config/database.js b/backend/config/database.js new file mode 100644 index 0000000..269da09 --- /dev/null +++ b/backend/config/database.js @@ -0,0 +1,20 @@ +const mongoose = require('mongoose'); + +const connectDB = async () => { + try { + const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/oramap'; + + await mongoose.connect(mongoURI, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + console.log('β MongoDB connected successfully'); + console.log(`π Database: ${mongoose.connection.name}`); + } catch (error) { + console.error('β MongoDB connection error:', error.message); + process.exit(1); + } +}; + +module.exports = connectDB; diff --git a/backend/models/Family.js b/backend/models/Family.js new file mode 100644 index 0000000..79b7842 --- /dev/null +++ b/backend/models/Family.js @@ -0,0 +1,33 @@ +const mongoose = require('mongoose'); + +const familySchema = new mongoose.Schema({ + family: { + type: String, + required: true, + trim: true + }, + city: { + type: String, + required: true, + trim: true + }, + lat: { + type: Number, + required: true, + min: -90, + max: 90 + }, + lng: { + type: Number, + required: true, + min: -180, + max: 180 + } +}, { + timestamps: true +}); + +// Index for faster searches +familySchema.index({ family: 'text', city: 'text' }); + +module.exports = mongoose.model('Family', familySchema); diff --git a/backend/package.json b/backend/package.json index c8ec596..38f0a1b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,13 +5,16 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "node server.js" + "dev": "node server.js", + "seed": "node scripts/seed.js", + "seed:force": "node scripts/seed.js --force" }, "keywords": ["map", "family", "leaflet", "express"], "author": "", "license": "ISC", "dependencies": { "express": "^4.18.2", - "cors": "^2.8.5" + "cors": "^2.8.5", + "mongoose": "^7.6.0" } } diff --git a/backend/scripts/seed.js b/backend/scripts/seed.js new file mode 100644 index 0000000..506a0db --- /dev/null +++ b/backend/scripts/seed.js @@ -0,0 +1,59 @@ +const mongoose = require('mongoose'); +const Family = require('../models/Family'); + +const initialData = [ + { "family": "Kafe (Χ§ΧΧ€Χ)", "city": "Sana'a (Χ¦Χ Χ’Χ)", "lat": 15.3545, "lng": 44.2064 }, + { "family": "Shiheb (Χ©ΧΧ-Χ©ΧΧ)", "city": "Sana'a (Χ¦Χ Χ’Χ)", "lat": 15.3545, "lng": 44.2064 }, + { "family": "Uzeyri (Χ’ΧΧΧ¨Χ-Χ’ΧΧΧ¨Χ)", "city": "Sana'a (Χ¦Χ Χ’Χ)", "lat": 15.3545, "lng": 44.2064 }, + { "family": "Uzeyri (Χ’ΧΧΧ¨Χ-Χ’ΧΧΧ¨Χ)", "city": "Manakhah (ΧΧ ΧΧΧ)", "lat": 15.3019, "lng": 43.5983 }, + { "family": "Uzeyri (Χ’ΧΧΧ¨Χ-Χ’ΧΧΧ¨Χ)", "city": "Dhamar (Χ'ΧΧΧ¨)", "lat": 14.5424, "lng": 44.4056 }, + { "family": "Salumi (Χ‘ΧΧΧΧ-Χ©ΧΧΧΧ)", "city": "Al Kafla (ΧΧ Χ§Χ€ΧΧ)", "lat": 16.0240, "lng": 43.9790 }, + { "family": "Afgin (Χ’Χ€Χ'ΧΧ)", "city": "Sa'dah (Χ¦Χ’ΧΧ)", "lat": 16.9402, "lng": 43.7639 }, + { "family": "Eraki (Χ’Χ¨ΧΧ§Χ)", "city": "Sana'a (Χ¦Χ Χ’Χ)", "lat": 15.3545, "lng": 44.2064 } +]; + +async function seedDatabase() { + try { + const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/oramap'; + + await mongoose.connect(mongoURI, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + console.log('β Connected to MongoDB'); + + // Check if data already exists + const existingCount = await Family.countDocuments(); + + if (existingCount > 0) { + console.log(`βΉοΈ Database already has ${existingCount} families`); + const answer = process.argv.includes('--force'); + + if (answer) { + console.log('ποΈ Clearing existing data...'); + await Family.deleteMany({}); + } else { + console.log('βΉοΈ Skipping seed. Use --force flag to override existing data'); + process.exit(0); + } + } + + // Insert initial data + console.log('π Seeding database...'); + const result = await Family.insertMany(initialData); + + console.log(`β Successfully seeded ${result.length} families`); + console.log('\nAdded families:'); + result.forEach(family => { + console.log(` - ${family.family} | ${family.city}`); + }); + + process.exit(0); + } catch (error) { + console.error('β Seed error:', error); + process.exit(1); + } +} + +seedDatabase(); diff --git a/backend/server.js b/backend/server.js index 1cb8c37..354e649 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,28 +1,156 @@ const express = require('express'); +const cors = require('cors'); const path = require('path'); -const app = express(); -const families = require('./data/families.json'); +const connectDB = require('./config/database'); +const Family = require('./models/Family'); -// Serve static files from the public directory -app.use(express.static(path.join(__dirname, '../public'))); +const app = express(); + +// Enable CORS for frontend +app.use(cors()); + +// Body parser middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Connect to MongoDB +connectDB(); + +// Serve static files from the public directory (for standalone mode) +if (process.env.SERVE_STATIC === 'true') { + app.use(express.static(path.join(__dirname, '../public'))); +} // API endpoint for family search -app.get('/api/search', (req, res) => { - const query = req.query.family?.toLowerCase(); - if (!query) { - return res.json([]); +app.get('/api/search', async (req, res) => { + try { + const query = req.query.family?.toLowerCase(); + + if (!query) { + return res.json([]); + } + + // Search by family name (case-insensitive) + const matches = await Family.find({ + family: { $regex: query, $options: 'i' } + }).select('-__v -createdAt -updatedAt'); + + res.json(matches); + } catch (error) { + console.error('Search error:', error); + res.status(500).json({ error: 'Search failed', message: error.message }); + } +}); + +// Get all families +app.get('/api/families', async (req, res) => { + try { + const families = await Family.find() + .select('-__v -createdAt -updatedAt') + .sort({ family: 1 }); + + res.json(families); + } catch (error) { + console.error('Get families error:', error); + res.status(500).json({ error: 'Failed to fetch families', message: error.message }); + } +}); + +// Create new family +app.post('/api/families', async (req, res) => { + try { + const { family, city, lat, lng } = req.body; + + // Validation + if (!family || !city || lat === undefined || lng === undefined) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['family', 'city', 'lat', 'lng'] + }); + } + + // Validate coordinates + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) { + return res.status(400).json({ + error: 'Invalid coordinates', + message: 'Latitude must be between -90 and 90, Longitude between -180 and 180' + }); + } + + const newFamily = new Family({ family, city, lat, lng }); + await newFamily.save(); + + res.status(201).json({ + message: 'Family added successfully', + family: newFamily + }); + } catch (error) { + console.error('Create family error:', error); + res.status(500).json({ error: 'Failed to create family', message: error.message }); + } +}); + +// Update family +app.put('/api/families/:id', async (req, res) => { + try { + const { id } = req.params; + const { family, city, lat, lng } = req.body; + + const updatedFamily = await Family.findByIdAndUpdate( + id, + { family, city, lat, lng }, + { new: true, runValidators: true } + ); + + if (!updatedFamily) { + return res.status(404).json({ error: 'Family not found' }); + } + + res.json({ + message: 'Family updated successfully', + family: updatedFamily + }); + } catch (error) { + console.error('Update family error:', error); + res.status(500).json({ error: 'Failed to update family', message: error.message }); + } +}); + +// Delete family +app.delete('/api/families/:id', async (req, res) => { + try { + const { id } = req.params; + + const deletedFamily = await Family.findByIdAndDelete(id); + + if (!deletedFamily) { + return res.status(404).json({ error: 'Family not found' }); + } + + res.json({ + message: 'Family deleted successfully', + family: deletedFamily + }); + } catch (error) { + console.error('Delete family error:', error); + res.status(500).json({ error: 'Failed to delete family', message: error.message }); } - const matches = families.filter(fam => fam.family.toLowerCase().includes(query)); - res.json(matches); }); // Health check endpoint app.get('/api/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); + const mongoose = require('mongoose'); + const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected'; + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + database: dbStatus + }); }); const port = process.env.PORT || 3000; app.listen(port, '0.0.0.0', () => { - console.log(`πΊοΈ Ora Map Server running at http://localhost:${port}`); - console.log(`π API endpoint: http://localhost:${port}/api/search`); + console.log(`πΊοΈ Ora Map Backend API running at http://localhost:${port}`); + console.log(`π Search endpoint: http://localhost:${port}/api/search`); + console.log(`π Health endpoint: http://localhost:${port}/api/health`); }); diff --git a/docker-compose.microservices.yml b/docker-compose.microservices.yml index 1bdf9b6..383825a 100644 --- a/docker-compose.microservices.yml +++ b/docker-compose.microservices.yml @@ -1,6 +1,26 @@ version: '3.8' services: + # MongoDB Database Service + mongo: + image: mongo:7.0 + container_name: oramap-mongo + environment: + - MONGO_INITDB_DATABASE=oramap + ports: + - "27017:27017" + volumes: + - mongo-data:/data/db + restart: unless-stopped + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - oramap-network + # Backend API Service backend: build: @@ -11,6 +31,10 @@ services: environment: - NODE_ENV=production - PORT=3000 + - MONGODB_URI=mongodb://mongo:27017/oramap + depends_on: + mongo: + condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] @@ -46,3 +70,7 @@ services: networks: oramap-network: driver: bridge + +volumes: + mongo-data: + driver: local diff --git a/frontend/public/index.html b/frontend/public/index.html index 86d16e9..19a00f8 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -12,9 +12,41 @@
+ +