This commit is contained in:
2025-12-23 17:41:30 -08:00
parent d6ac68f7d3
commit ff534eb448
18 changed files with 3989 additions and 461 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# Backend Configuration
NODE_ENV=production
PORT=4000
# Database
DATABASE_PATH=./backend/awards.db
# Admin Credentials (use bcrypt hash for password)
# Generate hash with: node -e "require('bcryptjs').hash('your-password', 10).then(console.log)"
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=
# CORS Configuration
CORS_ORIGIN=https://awards.scottyah.com
# Nominatim API Configuration
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
NOMINATIM_USER_AGENT=BestOfPBAwardsApp/1.0
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_NOMINATION_MAX=10
RATE_LIMIT_EMOJI_MAX=50

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.production
.env.production.local
# Database
*.db
*.db-shm
*.db-wal
# Backups
backups/
*.sql

304
CLAUDE.md Normal file
View File

@@ -0,0 +1,304 @@
# Best of Pacific Beach Awards - Technical Documentation
## Overview
A full-stack web application for nominating and voting on the best places in Pacific Beach, San Diego. Features a React frontend with interactive maps, emoji reactions, and an Express backend with SQLite database.
**Live URL:** https://awards.scottyah.com
## Architecture
### Technology Stack
- **Frontend:** React 19, Vite, React Leaflet (maps), React Router
- **Backend:** Express 5, SQLite3, Winston (logging)
- **Security:** bcryptjs, express-rate-limit, express-validator
- **Deployment:** Docker, Kubernetes, Harbor registry, Traefik ingress
### Key Features
- Interactive map showing award locations
- Emoji reactions with session tracking
- Admin management panel with basic auth
- Address validation (Pacific Beach 92109 only)
- Rate limiting and input validation
- Geocoding via Nominatim API
## Project Structure
```
react-awards-map/
├── backend/
│ ├── index.js # Main Express server
│ ├── migrate.js # Database migration script
│ ├── package.json
│ └── awards.db # SQLite database (gitignored)
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── EmojiPicker.jsx
│ │ │ └── EmojiPicker.css
│ │ ├── config/
│ │ │ └── api.js # API URL configuration
│ │ ├── App.jsx
│ │ ├── AwardsPage.jsx
│ │ └── ManagementPage.jsx
│ └── package.json
├── Dockerfile # Production container
├── k8s.yaml # Kubernetes manifests
├── deploy.sh # Deployment script
├── .env.example # Environment template
└── .env.local # Local environment (gitignored)
```
## Environment Configuration
### Backend (.env.local)
```bash
NODE_ENV=development
PORT=4000
DATABASE_PATH=./backend/awards.db
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=<bcrypt_hash>
CORS_ORIGIN=http://localhost:5173
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
NOMINATIM_USER_AGENT=BestOfPBAwardsApp/1.0
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_NOMINATION_MAX=10
RATE_LIMIT_EMOJI_MAX=50
```
### Frontend (frontend/.env.local)
```bash
VITE_API_URL=http://localhost:4000
```
### Production Environment
The Kubernetes deployment uses secrets for sensitive values:
- `ADMIN_USERNAME` and `ADMIN_PASSWORD_HASH` are stored in `awards-secret`
- Other environment variables are set directly in k8s.yaml
## Development
### Setup
```bash
# Install backend dependencies
cd backend
npm install
# Install frontend dependencies
cd ../frontend
npm install
```
### Running Locally
```bash
# Terminal 1: Start backend
cd backend
node index.js
# Terminal 2: Start frontend
cd frontend
npm run dev
```
Access the app at http://localhost:5173
### Database Operations
```bash
# Create tables
node backend/migrate.js create
# Seed with sample data
node backend/migrate.js seed
# Reset and seed
node backend/migrate.js reset:seed
# Backup database
node backend/migrate.js backup
```
## Production Deployment
### Prerequisites
- Docker installed and logged into harbor.scottyah.com
- kubectl configured for your Kubernetes cluster
- `awards` namespace created in Kubernetes
- Traefik ingress controller installed
- TLS secret `awards-tls` configured for awards.scottyah.com
### Deployment Process
```bash
# 1. Ensure .env.local contains production secrets
# (ADMIN_USERNAME and ADMIN_PASSWORD_HASH)
# 2. Run deployment script
./deploy.sh [tag]
# The script will:
# - Build Docker image with frontend and backend
# - Push to harbor.scottyah.com/secure/awards
# - Create Kubernetes secrets from .env.local
# - Apply k8s.yaml manifests
# - Restart the deployment
```
### Manual Deployment Steps
If you need to deploy manually:
```bash
# Build and push
docker build -t harbor.scottyah.com/secure/awards:latest .
docker push harbor.scottyah.com/secure/awards:latest
# Create namespace (if not exists)
kubectl create namespace awards
# Create secrets
kubectl create secret generic awards-secret -n awards \
--from-literal=admin-username='admin' \
--from-literal=admin-password-hash='$2b$10$...'
# Apply manifests
kubectl apply -f k8s.yaml
# Restart deployment
kubectl rollout restart deployment/awards-dep -n awards
```
## API Endpoints
### Public Endpoints
- `GET /awards` - Get all approved awards
- `GET /awards/top` - Get top 5 awards by emoji reactions
- `POST /awards` - Submit new award nomination
- `PATCH /awards/:id/emojis` - Add emoji reaction
- `PATCH /awards/:id/emojis/remove` - Remove emoji reaction
- `GET /health` - Health check endpoint
### Admin Endpoints (require Basic Auth)
- `GET /awards/pending` - Get pending (unapproved) awards
- `PATCH /awards/:id/approve` - Approve an award
- `DELETE /awards/:id` - Reject/delete an award
### Rate Limits
- General: 100 requests per 15 minutes
- Nominations: 10 per 15 minutes
- Emoji reactions: 50 per 15 minutes
## Security Features
### Implemented (Priority 1 & 2)
- ✅ Environment-based configuration (no hardcoded values)
- ✅ CORS restricted to awards.scottyah.com
- ✅ Bcrypt password hashing for admin auth
- ✅ Rate limiting on all endpoints
- ✅ Input validation with express-validator
- ✅ Structured logging with Winston
- ✅ Graceful shutdown handling
- ✅ Health check endpoint
- ✅ Production static file serving
- ✅ Removed duplicate geocoding (uses server lat/lng)
- ✅ Extracted shared EmojiPicker component
### Not Yet Implemented (Priority 3)
- ❌ Automated tests
- ❌ Geocoding result caching
- ❌ Pagination for awards endpoint
- ❌ TypeScript migration
- ❌ Database migrations system
## Database Schema
### awards table
```sql
CREATE TABLE awards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
address TEXT NOT NULL,
submitted_by VARCHAR(255),
emoji_tally TEXT DEFAULT '{}', -- JSON string
submitted_date DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_date DATETIME,
lat REAL,
lng REAL
);
```
## Monitoring & Logs
### Application Logs
- **Production:** Logs written to `/app/error.log` and `/app/combined.log`
- **Development:** Logs output to console
### Kubernetes Logs
```bash
# View pod logs
kubectl logs -n awards -l app=awards --tail=100 -f
# Check deployment status
kubectl get pods -n awards
kubectl describe deployment awards-dep -n awards
```
### Health Checks
- Liveness probe: `GET /health` every 30s
- Readiness probe: Same as liveness
## Troubleshooting
### Common Issues
**Issue:** Geocoding returns "not in Pacific Beach"
- **Solution:** Address must contain "92109" zip code or "Pacific Beach" in the result
**Issue:** Admin login fails
- **Solution:** Check ADMIN_PASSWORD_HASH is correctly set in secrets
```bash
kubectl get secret awards-secret -n awards -o yaml
```
**Issue:** Frontend shows "Failed to fetch"
- **Solution:** Check CORS_ORIGIN matches the frontend domain
- Verify backend is running: `kubectl get pods -n awards`
**Issue:** Database resets after pod restart
- **Solution:** Ensure PersistentVolumeClaim is properly mounted
```bash
kubectl get pvc -n awards
kubectl describe pvc awards-pvc -n awards
```
## Performance Considerations
1. **Geocoding:** Server geocodes addresses once on submission; frontend uses cached lat/lng
2. **Emoji Tally:** Stored as JSON string; consider dedicated table if tallies grow large
3. **Map Rendering:** Memoized to prevent unnecessary re-renders
4. **Static Assets:** Frontend built and served from Express in production
## Future Enhancements
- Add caching for geocoding results
- Implement pagination for large award lists
- Add user accounts and authentication
- Create admin dashboard with analytics
- Add email notifications for new nominations
- Implement search and filtering
- Add categories system
- Mobile app version
## Admin Credentials
**Development:**
- Username: `admin`
- Password: `AwardsPB2024!Secure`
**Production:**
- Stored in Kubernetes secret `awards-secret`
- Generate new hash: `node -e "import('bcryptjs').then(b => b.default.hash('your-password', 10).then(console.log))"`
## Support & Maintenance
For issues or questions, contact the development team or create an issue in the repository.
Last updated: December 2024

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# Multi-stage build for React Awards Map
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
COPY frontend/package*.json ./
# Install frontend dependencies
RUN npm ci
# Copy frontend source
COPY frontend/ ./
# Build frontend for production
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy backend package files
COPY backend/package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy backend source
COPY backend/ ./
# Copy built frontend from builder stage
COPY --from=frontend-builder /app/frontend/dist ./dist
# Create directory for database
RUN mkdir -p /app/data
# Expose port
EXPOSE 4000
# Set production environment
ENV NODE_ENV=production
ENV DATABASE_PATH=/app/data/awards.db
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:4000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", "index.js"]

View File

@@ -1,18 +1,67 @@
const express = require('express'); import 'dotenv/config';
const sqlite3 = require('sqlite3').verbose(); import express from 'express';
const cors = require('cors'); import sqlite3 from 'sqlite3';
const path = require('path'); import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import bcrypt from 'bcryptjs';
import rateLimit from 'express-rate-limit';
import { body, param, validationResult } from 'express-validator';
import winston from 'winston';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Logger setup
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: path.join(__dirname, 'error.log'), level: 'error' }),
new winston.transports.File({ filename: path.join(__dirname, 'combined.log') }),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
const app = express(); const app = express();
const PORT = 4000; const PORT = process.env.PORT || 4000;
// Middleware // CORS Configuration
app.use(cors()); const corsOptions = {
app.use(express.json()); origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
app.use(express.json({ limit: '10mb' }));
// Serve static frontend files in production
if (process.env.NODE_ENV === 'production') {
const frontendPath = path.join(__dirname, '..', 'dist');
app.use(express.static(frontendPath));
}
// SQLite setup // SQLite setup
const dbPath = path.join(__dirname, 'awards.db'); const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'awards.db');
const db = new sqlite3.Database(dbPath); const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
logger.error('Failed to connect to database:', err);
process.exit(1);
}
logger.info('Connected to SQLite database');
});
db.serialize(() => { db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS awards ( db.run(`CREATE TABLE IF NOT EXISTS awards (
@@ -28,27 +77,86 @@ db.serialize(() => {
)`); )`);
}); });
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); // Rate limiters
const generalLimiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
message: { error: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// Basic auth middleware for management endpoints const nominationLimiter = rateLimit({
const basicAuth = (req, res, next) => { windowMs: 15 * 60 * 1000, // 15 minutes
max: parseInt(process.env.RATE_LIMIT_NOMINATION_MAX) || 10,
message: { error: 'Too many nominations, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
const emojiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_EMOJI_MAX) || 50,
message: { error: 'Too many emoji reactions, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/awards', generalLimiter);
// Basic auth middleware with bcrypt
const basicAuth = async (req, res, next) => {
const auth = req.headers.authorization; const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Basic ')) { if (!auth || !auth.startsWith('Basic ')) {
res.set('WWW-Authenticate', 'Basic realm="Management"'); res.set('WWW-Authenticate', 'Basic realm="Management"');
logger.warn('Authentication required - no credentials provided');
return res.status(401).send('Authentication required.'); return res.status(401).send('Authentication required.');
} }
const credentials = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
const [user, pass] = credentials; try {
if (user === 'admin' && pass === 'password') return next(); const credentials = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
res.set('WWW-Authenticate', 'Basic realm="Management"'); const [user, pass] = credentials;
return res.status(401).send('Invalid credentials.');
const expectedUser = process.env.ADMIN_USERNAME || 'admin';
const expectedHash = process.env.ADMIN_PASSWORD_HASH;
if (!expectedHash) {
logger.error('ADMIN_PASSWORD_HASH not configured');
return res.status(500).json({ error: 'Server configuration error' });
}
if (user === expectedUser && await bcrypt.compare(pass, expectedHash)) {
logger.info('Successful admin authentication');
return next();
}
logger.warn('Failed admin authentication attempt');
res.set('WWW-Authenticate', 'Basic realm="Management"');
return res.status(401).send('Invalid credentials.');
} catch (error) {
logger.error('Error in authentication:', error);
res.set('WWW-Authenticate', 'Basic realm="Management"');
return res.status(401).send('Authentication error.');
}
};
// Validation middleware
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.warn('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
next();
}; };
// Get all awards // Get all awards
app.get('/awards', (req, res) => { app.get('/awards', (req, res) => {
db.all('SELECT * FROM awards ORDER BY submitted_date DESC', [], (err, rows) => { db.all('SELECT * FROM awards WHERE approved_date IS NOT NULL ORDER BY submitted_date DESC', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message }); if (err) {
// Parse emoji_tally JSON for each row logger.error('Error fetching awards:', err);
return res.status(500).json({ error: 'Failed to fetch awards' });
}
const awards = rows.map(row => ({ const awards = rows.map(row => ({
...row, ...row,
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {} emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
@@ -58,121 +166,188 @@ app.get('/awards', (req, res) => {
}); });
// Add a new award // Add a new award
app.post('/awards', async (req, res) => { app.post('/awards',
const { category, address, submitted_by } = req.body; nominationLimiter,
if (!category || !address) { body('category').trim().isLength({ min: 1, max: 200 }).withMessage('Category must be 1-200 characters'),
return res.status(400).json({ error: 'Category and address are required.' }); body('address').trim().isLength({ min: 1, max: 500 }).withMessage('Address must be 1-500 characters'),
} body('submitted_by').optional().trim().isLength({ max: 255 }).withMessage('submitted_by must be max 255 characters'),
let lat = null, lng = null; validate,
let isPB = false; async (req, res) => {
try { const { category, address, submitted_by } = req.body;
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}`,
{ headers: { 'User-Agent': 'react-awards-map/1.0 (contact@yourdomain.com)' } }); let lat = null, lng = null;
const data = await resp.json(); let isPB = false;
if (data && data[0]) {
lat = parseFloat(data[0].lat); try {
lng = parseFloat(data[0].lon); const nominatimUrl = process.env.NOMINATIM_BASE_URL || 'https://nominatim.openstreetmap.org';
// Check if address is in Pacific Beach const userAgent = process.env.NOMINATIM_USER_AGENT || 'BestOfPBAwardsApp/1.0';
const displayName = data[0].display_name || '';
const addressObj = data[0].address || {}; const resp = await fetch(
// Accept if display_name contains 'Pacific Beach' or zip is 92109 `${nominatimUrl}/search?format=json&q=${encodeURIComponent(address)}`,
if ( { headers: { 'User-Agent': userAgent } }
displayName.toLowerCase().includes('pacific beach') || );
addressObj.suburb === 'Pacific Beach' ||
addressObj.neighbourhood === 'Pacific Beach' || const data = await resp.json();
addressObj.city === 'San Diego' && addressObj.postcode === '92109' ||
addressObj.postcode === '92109' if (data && data[0]) {
) { lat = parseFloat(data[0].lat);
isPB = true; lng = parseFloat(data[0].lon);
}
} const displayName = data[0].display_name || '';
} catch (e) { /* ignore geocode errors */ } const addressObj = data[0].address || {};
if (!isPB) {
return res.status(400).json({ error: 'Address must be in Pacific Beach (92109, San Diego, CA).' }); if (
} displayName.toLowerCase().includes('pacific beach') ||
db.run( addressObj.suburb === 'Pacific Beach' ||
'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)', addressObj.neighbourhood === 'Pacific Beach' ||
[category, address, submitted_by || null, '{}', lat, lng], (addressObj.city === 'San Diego' && addressObj.postcode === '92109') ||
function (err) { addressObj.postcode === '92109'
if (err) return res.status(500).json({ error: err.message }); ) {
db.get('SELECT * FROM awards WHERE id = ?', [this.lastID], (err, row) => { isPB = true;
if (err) return res.status(500).json({ error: err.message }); }
row.emoji_tally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {}; }
res.status(201).json(row); } catch (e) {
}); logger.error('Geocoding error:', e);
} }
);
}); if (!isPB) {
logger.warn('Invalid address submitted:', address);
return res.status(400).json({ error: 'Address must be in Pacific Beach (92109, San Diego, CA).' });
}
// Update emoji tally for an award
app.patch('/awards/:id/emojis', (req, res) => {
const { id } = req.params;
const { emoji, count } = req.body;
if (!emoji || typeof count !== 'number') {
return res.status(400).json({ error: 'Emoji and count are required.' });
}
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => {
if (err || !row) return res.status(404).json({ error: 'Award not found.' });
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
emojiTally[emoji] = count;
db.run( db.run(
'UPDATE awards SET emoji_tally = ? WHERE id = ?', 'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)',
[JSON.stringify(emojiTally), id], [category, address, submitted_by || null, '{}', lat, lng],
function (err) { function (err) {
if (err) return res.status(500).json({ error: err.message }); if (err) {
res.json({ id, emoji_tally: emojiTally }); logger.error('Error inserting award:', err);
return res.status(500).json({ error: 'Failed to create award' });
}
db.get('SELECT * FROM awards WHERE id = ?', [this.lastID], (err, row) => {
if (err) {
logger.error('Error fetching created award:', err);
return res.status(500).json({ error: 'Award created but failed to fetch' });
}
row.emoji_tally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
logger.info('Award created:', { id: row.id, category, address });
res.status(201).json(row);
});
} }
); );
});
});
// Remove or decrement an emoji tally for an award
app.patch('/awards/:id/emojis/remove', (req, res) => {
const { id } = req.params;
const { emoji } = req.body;
if (!emoji) {
return res.status(400).json({ error: 'Emoji is required.' });
} }
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => { );
if (err || !row) return res.status(404).json({ error: 'Award not found.' });
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {}; // Update emoji tally for an award
if (emojiTally[emoji]) { app.patch('/awards/:id/emojis',
emojiTally[emoji] = emojiTally[emoji] - 1; emojiLimiter,
if (emojiTally[emoji] <= 0) { param('id').isInt().withMessage('Invalid award ID'),
delete emojiTally[emoji]; body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'),
body('count').isInt({ min: 0, max: 1000 }).withMessage('Count must be between 0 and 1000'),
validate,
(req, res) => {
const { id } = req.params;
const { emoji, count } = req.body;
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => {
if (err || !row) {
logger.warn('Award not found for emoji update:', id);
return res.status(404).json({ error: 'Award not found.' });
} }
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
emojiTally[emoji] = count;
db.run( db.run(
'UPDATE awards SET emoji_tally = ? WHERE id = ?', 'UPDATE awards SET emoji_tally = ? WHERE id = ?',
[JSON.stringify(emojiTally), id], [JSON.stringify(emojiTally), id],
function (err) { function (err) {
if (err) return res.status(500).json({ error: err.message }); if (err) {
logger.error('Error updating emoji tally:', err);
return res.status(500).json({ error: 'Failed to update emoji' });
}
res.json({ id, emoji_tally: emojiTally }); res.json({ id, emoji_tally: emojiTally });
} }
); );
} else { });
res.json({ id, emoji_tally: emojiTally }); }
} );
});
}); // Remove or decrement an emoji tally for an award
app.patch('/awards/:id/emojis/remove',
emojiLimiter,
param('id').isInt().withMessage('Invalid award ID'),
body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'),
validate,
(req, res) => {
const { id } = req.params;
const { emoji } = req.body;
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => {
if (err || !row) {
logger.warn('Award not found for emoji removal:', id);
return res.status(404).json({ error: 'Award not found.' });
}
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
if (emojiTally[emoji]) {
emojiTally[emoji] = emojiTally[emoji] - 1;
if (emojiTally[emoji] <= 0) {
delete emojiTally[emoji];
}
db.run(
'UPDATE awards SET emoji_tally = ? WHERE id = ?',
[JSON.stringify(emojiTally), id],
function (err) {
if (err) {
logger.error('Error removing emoji:', err);
return res.status(500).json({ error: 'Failed to remove emoji' });
}
res.json({ id, emoji_tally: emojiTally });
}
);
} else {
res.json({ id, emoji_tally: emojiTally });
}
});
}
);
// Approve an award // Approve an award
app.patch('/awards/:id/approve', basicAuth, (req, res) => { app.patch('/awards/:id/approve',
const { id } = req.params; basicAuth,
db.run( param('id').isInt().withMessage('Invalid award ID'),
'UPDATE awards SET approved_date = CURRENT_TIMESTAMP WHERE id = ?', validate,
[id], (req, res) => {
function (err) { const { id } = req.params;
if (err) return res.status(500).json({ error: err.message });
if (this.changes === 0) return res.status(404).json({ error: 'Award not found.' }); db.run(
res.json({ id, approved_date: new Date().toISOString() }); 'UPDATE awards SET approved_date = CURRENT_TIMESTAMP WHERE id = ?',
} [id],
); function (err) {
}); if (err) {
logger.error('Error approving award:', err);
return res.status(500).json({ error: 'Failed to approve award' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Award not found.' });
}
logger.info('Award approved:', id);
res.json({ id, approved_date: new Date().toISOString() });
}
);
}
);
// Get all pending (unapproved) awards // Get all pending (unapproved) awards
app.get('/awards/pending', basicAuth, (req, res) => { app.get('/awards/pending', basicAuth, (req, res) => {
db.all('SELECT * FROM awards WHERE approved_date IS NULL ORDER BY submitted_date DESC', [], (err, rows) => { db.all('SELECT * FROM awards WHERE approved_date IS NULL ORDER BY submitted_date DESC', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message }); if (err) {
logger.error('Error fetching pending awards:', err);
return res.status(500).json({ error: 'Failed to fetch pending awards' });
}
const awards = rows.map(row => ({ const awards = rows.map(row => ({
...row, ...row,
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {} emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
@@ -182,39 +357,85 @@ app.get('/awards/pending', basicAuth, (req, res) => {
}); });
// Reject (delete) a nomination // Reject (delete) a nomination
app.delete('/awards/:id', basicAuth, (req, res) => { app.delete('/awards/:id',
const { id } = req.params; basicAuth,
db.run('DELETE FROM awards WHERE id = ?', [id], function (err) { param('id').isInt().withMessage('Invalid award ID'),
if (err) return res.status(500).json({ error: err.message }); validate,
if (this.changes === 0) return res.status(404).json({ error: 'Award not found.' }); (req, res) => {
res.json({ id, deleted: true }); const { id } = req.params;
});
}); db.run('DELETE FROM awards WHERE id = ?', [id], function (err) {
if (err) {
logger.error('Error deleting award:', err);
return res.status(500).json({ error: 'Failed to delete award' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Award not found.' });
}
logger.info('Award deleted:', id);
res.json({ id, deleted: true });
});
}
);
// Get top 5 awards approved, sorted by emoji tally (descending) // Get top 5 awards approved, sorted by emoji tally (descending)
app.get('/awards/top', (req, res) => { app.get('/awards/top', (req, res) => {
db.all( db.all(
`SELECT * FROM awards WHERE approved_date IS NOT NULL`, 'SELECT * FROM awards WHERE approved_date IS NOT NULL',
[], [],
(err, rows) => { (err, rows) => {
if (err) return res.status(500).json({ error: err.message }); if (err) {
// Parse emoji_tally and calculate total emojis for sorting logger.error('Error fetching top awards:', err);
return res.status(500).json({ error: 'Failed to fetch top awards' });
}
const awards = rows.map(row => { const awards = rows.map(row => {
const emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {}; const emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
const emojiTotal = Object.values(emojiTally).reduce((sum, n) => sum + Number(n), 0); const emojiTotal = Object.values(emojiTally).reduce((sum, n) => sum + Number(n), 0);
return { ...row, emoji_tally: emojiTally, emojiTotal }; return { ...row, emoji_tally: emojiTally, emojiTotal };
}); });
// Sort by emojiTotal descending, then by most recently approved
awards.sort((a, b) => { awards.sort((a, b) => {
if (b.emojiTotal !== a.emojiTotal) return b.emojiTotal - a.emojiTotal; if (b.emojiTotal !== a.emojiTotal) return b.emojiTotal - a.emojiTotal;
return new Date(b.approved_date) - new Date(a.approved_date); return new Date(b.approved_date) - new Date(a.approved_date);
}); });
res.json(awards.slice(0, 5)); res.json(awards.slice(0, 5));
} }
); );
}); });
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve frontend in production
if (process.env.NODE_ENV === 'production') {
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});
}
// Graceful shutdown
const gracefulShutdown = () => {
logger.info('Received shutdown signal, closing database connection...');
db.close((err) => {
if (err) {
logger.error('Error closing database:', err);
process.exit(1);
}
logger.info('Database connection closed, exiting...');
process.exit(0);
});
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
// Start server // Start server
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Backend server running on http://localhost:${PORT}`); logger.info(`Backend server running on http://localhost:${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
logger.info(`CORS origin: ${corsOptions.origin}`);
}); });

220
backend/migrate.js Normal file
View File

@@ -0,0 +1,220 @@
import 'dotenv/config';
import sqlite3 from 'sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'awards.db');
// Migration commands
const command = process.argv[2];
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Failed to connect to database:', err);
process.exit(1);
}
});
// Create tables
function createTables() {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS awards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
address TEXT NOT NULL,
submitted_by VARCHAR(255),
emoji_tally TEXT DEFAULT '{}',
submitted_date DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_date DATETIME,
lat REAL,
lng REAL
)`, (err) => {
if (err) {
console.error('Error creating awards table:', err);
reject(err);
} else {
console.log('✓ Created awards table');
resolve();
}
});
});
});
}
// Seed sample data
async function seed() {
const seedData = [
{
category: 'Best Pizza',
address: '1188 Garnet Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7977,
lng: -117.2514,
approved: true
},
{
category: 'Best Coffee Shop',
address: '4876 Santa Monica Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7959,
lng: -117.2346,
approved: true
},
{
category: 'Best Taco Shop',
address: '1830 Garnet Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7980,
lng: -117.2420,
approved: true
},
{
category: 'Best Sunset View',
address: 'Crystal Pier, Pacific Beach, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7965,
lng: -117.2547,
approved: true
},
{
category: 'Best Ice Cream',
address: '1025 Garnet Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7975,
lng: -117.2470,
approved: true
},
{
category: 'Best Breakfast Spot',
address: '4970 Cass St, San Diego, CA 92109',
submitted_by: 'local_foodie',
lat: 32.7962,
lng: -117.2580,
approved: false
}
];
console.log('Seeding database...');
for (const item of seedData) {
await new Promise((resolve, reject) => {
const approvedDate = item.approved ? new Date().toISOString() : null;
db.run(
'INSERT INTO awards (category, address, submitted_by, emoji_tally, lat, lng, approved_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
[item.category, item.address, item.submitted_by, '{}', item.lat, item.lng, approvedDate],
function (err) {
if (err) {
console.error(`✗ Failed to seed "${item.category}":`, err.message);
reject(err);
} else {
console.log(`✓ Seeded: ${item.category}`);
resolve();
}
}
);
});
}
console.log('✓ Database seeded successfully');
}
// Reset database (drop all tables)
function reset() {
return new Promise((resolve, reject) => {
db.run('DROP TABLE IF EXISTS awards', (err) => {
if (err) {
console.error('Error dropping awards table:', err);
reject(err);
} else {
console.log('✓ Dropped awards table');
resolve();
}
});
});
}
// Backup database
function backup() {
const backupDir = path.join(__dirname, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(backupDir, `awards-${timestamp}.db`);
fs.copyFileSync(dbPath, backupPath);
console.log(`✓ Database backed up to: ${backupPath}`);
}
// Main execution
async function main() {
try {
switch (command) {
case 'create':
console.log('Creating tables...');
await createTables();
console.log('✓ Migration complete');
break;
case 'seed':
console.log('Seeding database...');
await seed();
console.log('✓ Seed complete');
break;
case 'reset':
console.log('Resetting database...');
await reset();
await createTables();
console.log('✓ Database reset complete');
break;
case 'reset:seed':
console.log('Resetting and seeding database...');
await reset();
await createTables();
await seed();
console.log('✓ Reset and seed complete');
break;
case 'backup':
console.log('Backing up database...');
backup();
break;
default:
console.log(`
Usage: node migrate.js <command>
Commands:
create Create database tables
seed Seed database with sample data
reset Drop all tables and recreate them
reset:seed Reset database and seed with sample data
backup Create a backup of the database
Examples:
node migrate.js create
node migrate.js seed
node migrate.js reset:seed
node migrate.js backup
`);
process.exit(0);
}
db.close();
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
db.close();
process.exit(1);
}
}
main();

2617
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,14 @@
"seed": "node seed.js" "seed": "node seed.js"
}, },
"dependencies": { "dependencies": {
"express": "^5.1.0", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"node-fetch": "^3.3.2",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"node-fetch": "^3.3.2" "winston": "^3.19.0"
} }
} }

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# API Configuration
VITE_API_URL=http://localhost:4000

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import './App.css' import './App.css';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet' import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet' import L from 'leaflet';
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css';
import AwardsPage from './AwardsPage' import AwardsPage from './AwardsPage';
import ManagementPage from './ManagementPage' import ManagementPage from './ManagementPage';
import emojiData from './emoji-data.json' import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api';
const defaultCenter = [32.7977, -117.2514]; const defaultCenter = [32.7977, -117.2514];
const defaultZoom = 14; const defaultZoom = 14;
@@ -30,69 +31,6 @@ const selectedIcon = new L.Icon({
className: 'selected-marker', className: 'selected-marker',
}); });
// Dynamically generate emoji categories from emojiData (browser compatible)
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
// Optionally, limit the number of categories/emojis for UI sanity
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags') // skip less useful
.slice(0, 6) // limit to 6 categories
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)]) // limit to 30 per category
);
function EmojiPicker({ isOpen, onClose, onSelect, position, emojiPickerOpen, awards }) {
if (!isOpen) return null;
const award = typeof emojiPickerOpen === 'number' && awards && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
function HomePage() { function HomePage() {
const [selectedIdx, setSelectedIdx] = useState(null); const [selectedIdx, setSelectedIdx] = useState(null);
const [awards, setAwards] = useState([]); const [awards, setAwards] = useState([]);
@@ -104,18 +42,16 @@ function HomePage() {
const [voteSubmittedBy, setVoteSubmittedBy] = useState(""); const [voteSubmittedBy, setVoteSubmittedBy] = useState("");
const [emojiPickerOpen, setEmojiPickerOpen] = useState(null); const [emojiPickerOpen, setEmojiPickerOpen] = useState(null);
const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 }); const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 });
const [awardCoords, setAwardCoords] = useState({});
// Fetch awards from backend // Fetch awards from backend
useEffect(() => { useEffect(() => {
fetch('http://localhost:4000/awards/top') fetch(`${API_URL}/awards/top`)
.then(res => { .then(res => {
if (!res.ok) throw new Error('Failed to fetch awards'); if (!res.ok) throw new Error('Failed to fetch awards');
return res.json(); return res.json();
}) })
.then(data => { .then(data => {
// Only show approved awards (approved_date not null) setAwards(data);
setAwards(data.filter(a => a.approved_date));
setLoading(false); setLoading(false);
}) })
.catch(err => { .catch(err => {
@@ -124,36 +60,10 @@ function HomePage() {
}); });
}, []); }, []);
// Geocode addresses for map
useEffect(() => {
const fetchCoords = async () => {
const coords = {};
for (const award of awards.slice(0, 5)) {
if (award.address && !awardCoords[award.address]) {
try {
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(award.address)}`);
const data = await resp.json();
if (data && data[0]) {
coords[award.address] = {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon)
};
}
} catch (e) { /* ignore */ }
} else if (awardCoords[award.address]) {
coords[award.address] = awardCoords[award.address];
}
}
setAwardCoords(prev => ({ ...prev, ...coords }));
};
if (awards.length > 0) fetchCoords();
// eslint-disable-next-line
}, [awards]);
// Find the selected award for the map // Find the selected award for the map
const selectedAward = selectedIdx !== null ? awards[selectedIdx] : null; const selectedAward = selectedIdx !== null ? awards[selectedIdx] : null;
// Dynamically create mapAwards from backend lat/lng only // Use server's lat/lng for map markers (no duplicate geocoding)
const mapAwards = useMemo(() => const mapAwards = useMemo(() =>
awards.slice(0, 5).filter(a => a.lat && a.lng).map((award) => ({ ...award, lat: award.lat, lng: award.lng })) awards.slice(0, 5).filter(a => a.lat && a.lng).map((award) => ({ ...award, lat: award.lat, lng: award.lng }))
, [awards]); , [awards]);
@@ -208,22 +118,20 @@ function HomePage() {
const key = `award-emoji-${award.id}`; const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}'); const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) { if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji setEmojiPickerOpen(null);
return; return;
} }
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 }; const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/awards/${award.id}/emojis`, { fetch(`${API_URL}/awards/${award.id}/emojis`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji, count: tally[emoji] }) body: JSON.stringify({ emoji, count: tally[emoji] })
}) })
.then(res => res.json()) .then(res => res.json())
.then(() => { .then(() => {
// Track in localStorage
sessionEmojis[emoji] = 1; sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis)); localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards return fetch(`${API_URL}/awards/top`);
return fetch('http://localhost:4000/awards/top');
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => setAwards(data)); .then(data => setAwards(data));
@@ -235,23 +143,20 @@ function HomePage() {
const handleEmojiRemove = (awardIdx, emoji) => { const handleEmojiRemove = (awardIdx, emoji) => {
const award = awards[awardIdx]; const award = awards[awardIdx];
if (!award || !award.emoji_tally[emoji]) return; if (!award || !award.emoji_tally[emoji]) return;
// Only allow if user added this emoji in this session
const key = `award-emoji-${award.id}`; const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}'); const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return; if (!sessionEmojis[emoji]) return;
fetch(`http://localhost:4000/awards/${award.id}/emojis/remove`, { fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji }) body: JSON.stringify({ emoji })
}) })
.then(res => res.json()) .then(res => res.json())
.then(() => { .then(() => {
// Update localStorage
sessionEmojis[emoji] = sessionEmojis[emoji] - 1; sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji]; if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis)); localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards return fetch(`${API_URL}/awards/top`);
return fetch('http://localhost:4000/awards/top');
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => setAwards(data)); .then(data => setAwards(data));
@@ -265,7 +170,7 @@ function HomePage() {
return; return;
} }
const submittedBy = voteSubmittedBy.trim() ? voteSubmittedBy.trim() : "anonymous"; const submittedBy = voteSubmittedBy.trim() ? voteSubmittedBy.trim() : "anonymous";
fetch('http://localhost:4000/awards', { fetch(`${API_URL}/awards`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy }) body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
@@ -279,14 +184,15 @@ function HomePage() {
setVoteCategory(""); setVoteCategory("");
setVoteSubmittedBy(""); setVoteSubmittedBy("");
setVoteError(""); setVoteError("");
// Refresh awards return fetch(`${API_URL}/awards/top`);
return fetch('http://localhost:4000/awards');
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => setAwards(data)) .then(data => setAwards(data))
.catch(err => setVoteError(err.message)); .catch(err => setVoteError(err.message));
}; };
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return ( return (
<div className="app"> <div className="app">
<header className="beachy-header"> <header className="beachy-header">
@@ -358,34 +264,7 @@ function HomePage() {
<section className="voting-section"> <section className="voting-section">
<h2 className="section-title">Nominate a Neighbor!</h2> <h2 className="section-title">Nominate a Neighbor!</h2>
<form className="voting-form" onSubmit={e => { <form className="voting-form" onSubmit={handleVoteSubmit}>
e.preventDefault();
if (!voteAddress.trim() || !voteCategory.trim()) {
setVoteError("Please enter both an address and a category/description.");
return;
}
const submittedBy = voteSubmittedBy.trim() ? voteSubmittedBy.trim() : "anonymous";
fetch('http://localhost:4000/awards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
})
.then(res => {
if (!res.ok) throw new Error('Failed to add award');
return res.json();
})
.then(() => {
setVoteAddress("");
setVoteCategory("");
setVoteSubmittedBy("");
setVoteError("");
// Refresh awards
return fetch('http://localhost:4000/awards');
})
.then(res => res.json())
.then(data => setAwards(data))
.catch(err => setVoteError(err.message));
}}>
<input <input
type="text" type="text"
placeholder="Address (e.g. 123 Main St)" placeholder="Address (e.g. 123 Main St)"
@@ -420,8 +299,7 @@ function HomePage() {
onClose={() => setEmojiPickerOpen(null)} onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect} onSelect={handleEmojiSelect}
position={emojiPickerPosition} position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen} award={currentAward}
awards={awards}
/> />
</div> </div>
) )
@@ -439,4 +317,4 @@ function App() {
) )
} }
export default App export default App;

View File

@@ -1,69 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
import './App.css' import './App.css';
import emojiData from './emoji-data.json' import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api';
// Emoji picker logic (copied from App.jsx)
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags')
.slice(0, 6)
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)])
);
function EmojiPicker({ isOpen, onClose, onSelect, position, emojiPickerOpen, awards }) {
if (!isOpen) return null;
const award = emojiPickerOpen !== null ? awards[emojiPickerOpen] : null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
function AwardsPage() { function AwardsPage() {
const [selectedAward, setSelectedAward] = useState(null); const [selectedAward, setSelectedAward] = useState(null);
@@ -75,7 +14,7 @@ function AwardsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
fetch('http://localhost:4000/awards') fetch(`${API_URL}/awards`)
.then(res => { .then(res => {
if (!res.ok) throw new Error('Failed to fetch awards'); if (!res.ok) throw new Error('Failed to fetch awards');
return res.json(); return res.json();
@@ -107,22 +46,20 @@ function AwardsPage() {
const key = `award-emoji-${award.id}`; const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}'); const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) { if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji setEmojiPickerOpen(null);
return; return;
} }
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 }; const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/awards/${award.id}/emojis`, { fetch(`${API_URL}/awards/${award.id}/emojis`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji, count: tally[emoji] }) body: JSON.stringify({ emoji, count: tally[emoji] })
}) })
.then(res => res.json()) .then(res => res.json())
.then(() => { .then(() => {
// Track in localStorage
sessionEmojis[emoji] = 1; sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis)); localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards return fetch(`${API_URL}/awards`);
return fetch('http://localhost:4000/awards');
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => setAwards(data)); .then(data => setAwards(data));
@@ -133,23 +70,20 @@ function AwardsPage() {
const handleEmojiRemove = (awardIdx, emoji) => { const handleEmojiRemove = (awardIdx, emoji) => {
const award = awards[awardIdx]; const award = awards[awardIdx];
if (!award || !award.emoji_tally[emoji]) return; if (!award || !award.emoji_tally[emoji]) return;
// Only allow if user added this emoji in this session
const key = `award-emoji-${award.id}`; const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}'); const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return; if (!sessionEmojis[emoji]) return;
fetch(`http://localhost:4000/awards/${award.id}/emojis/remove`, { fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji }) body: JSON.stringify({ emoji })
}) })
.then(res => res.json()) .then(res => res.json())
.then(() => { .then(() => {
// Update localStorage
sessionEmojis[emoji] = sessionEmojis[emoji] - 1; sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji]; if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis)); localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards return fetch(`${API_URL}/awards`);
return fetch('http://localhost:4000/awards');
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => setAwards(data)); .then(data => setAwards(data));
@@ -164,6 +98,8 @@ function AwardsPage() {
}); });
}; };
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return ( return (
<div className="app"> <div className="app">
<header className="beachy-header"> <header className="beachy-header">
@@ -251,11 +187,10 @@ function AwardsPage() {
onClose={() => setEmojiPickerOpen(null)} onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect} onSelect={handleEmojiSelect}
position={emojiPickerPosition} position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen} award={currentAward}
awards={awards}
/> />
</div> </div>
) )
} }
export default AwardsPage export default AwardsPage;

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import './App.css'; import './App.css';
import API_URL from './config/api';
function ManagementPage() { function ManagementPage() {
const [pendingAwards, setPendingAwards] = useState([]); const [pendingAwards, setPendingAwards] = useState([]);
@@ -13,7 +14,7 @@ function ManagementPage() {
const fetchPending = (creds) => { const fetchPending = (creds) => {
setLoading(true); setLoading(true);
fetch('http://localhost:4000/awards/pending', { fetch(`${API_URL}/awards/pending`, {
headers: { headers: {
'Authorization': 'Basic ' + btoa(`${creds.username}:${creds.password}`) 'Authorization': 'Basic ' + btoa(`${creds.username}:${creds.password}`)
} }
@@ -49,7 +50,7 @@ function ManagementPage() {
}, [credentials]); }, [credentials]);
const handleApprove = (id) => { const handleApprove = (id) => {
fetch(`http://localhost:4000/awards/${id}/approve`, { fetch(`${API_URL}/awards/${id}/approve`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`) 'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)
@@ -60,7 +61,7 @@ function ManagementPage() {
}; };
const handleReject = (id) => { const handleReject = (id) => {
fetch(`http://localhost:4000/awards/${id}`, { fetch(`${API_URL}/awards/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`) 'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)

View File

@@ -0,0 +1,105 @@
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.emoji-picker {
position: fixed;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
max-width: 400px;
max-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.emoji-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #eee;
background: #f9f9f9;
}
.emoji-picker-header span {
font-weight: 600;
font-size: 14px;
}
.emoji-picker-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-picker-close:hover {
color: #000;
}
.emoji-picker-content {
overflow-y: auto;
padding: 12px;
}
.emoji-category {
margin-bottom: 16px;
}
.emoji-category-title {
margin: 0 0 8px 0;
font-size: 12px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
gap: 4px;
}
.emoji-option {
background: none;
border: 1px solid transparent;
border-radius: 4px;
font-size: 20px;
cursor: pointer;
padding: 4px;
transition: all 0.2s;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-option:hover:not(:disabled) {
background: #f0f0f0;
border-color: #ddd;
transform: scale(1.1);
}
.emoji-option:disabled {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -0,0 +1,70 @@
import emojiData from '../emoji-data.json';
import './EmojiPicker.css';
// Dynamically generate emoji categories from emojiData
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
// Limit categories and emojis for UI
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags')
.slice(0, 6)
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)])
);
function EmojiPicker({ isOpen, onClose, onSelect, position, award }) {
if (!isOpen) return null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
export default EmojiPicker;
export { emojiCategories };

View File

@@ -0,0 +1,4 @@
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
export default API_URL;

153
k8s.yaml
View File

@@ -1,120 +1,123 @@
# Kubernetes manifest for Best of PB app ---
# Includes frontend, backend, services, and ingress apiVersion: v1
kind: Service
metadata:
name: awards-svc
namespace: awards
spec:
ports:
- port: 80
targetPort: 4000
protocol: TCP
selector:
app: awards
--- ---
# Backend Deployment
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: pb-backend name: awards-dep
labels: namespace: awards
app: pb-backend
spec: spec:
replicas: 1
selector: selector:
matchLabels: matchLabels:
app: pb-backend app: awards
replicas: 2
template: template:
metadata: metadata:
labels: labels:
app: pb-backend app: awards
spec: spec:
imagePullSecrets:
- name: harborcred
containers: containers:
- name: pb-backend - name: awards
image: harbor.scottyah.com/bestofpb/pb-backend:latest image: harbor.scottyah.com/secure/awards:latest
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 4000 - containerPort: 4000
env: env:
- name: NODE_ENV - name: NODE_ENV
value: "production" value: "production"
# Add volume mounts for persistent storage if needed - name: PORT
value: "4000"
- name: DATABASE_PATH
value: "/app/data/awards.db"
- name: CORS_ORIGIN
value: "https://awards.scottyah.com"
- name: ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: awards-secret
key: admin-username
- name: ADMIN_PASSWORD_HASH
valueFrom:
secretKeyRef:
name: awards-secret
key: admin-password-hash
- name: NOMINATIM_BASE_URL
value: "https://nominatim.openstreetmap.org"
- name: NOMINATIM_USER_AGENT
value: "BestOfPBAwardsApp/1.0"
- name: RATE_LIMIT_WINDOW_MS
value: "900000"
- name: RATE_LIMIT_MAX_REQUESTS
value: "100"
- name: RATE_LIMIT_NOMINATION_MAX
value: "10"
- name: RATE_LIMIT_EMOJI_MAX
value: "50"
volumeMounts:
- name: data
mountPath: /app/data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: data
persistentVolumeClaim:
claimName: awards-pvc
--- ---
# Backend Service
apiVersion: v1 apiVersion: v1
kind: Service kind: PersistentVolumeClaim
metadata: metadata:
name: pb-backend name: awards-pvc
namespace: awards
spec: spec:
selector: accessModes:
app: pb-backend - ReadWriteOnce
ports: resources:
- protocol: TCP requests:
port: 4000 storage: 1Gi
targetPort: 4000
--- ---
# Frontend Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: pb-frontend
labels:
app: pb-frontend
spec:
replicas: 1
selector:
matchLabels:
app: pb-frontend
template:
metadata:
labels:
app: pb-frontend
spec:
containers:
- name: pb-frontend
image: harbor.scottyah.com/bestofpb/pb-frontend:latest
imagePullPolicy: Always
ports:
- containerPort: 80
---
# Frontend Service
apiVersion: v1
kind: Service
metadata:
name: pb-frontend
spec:
selector:
app: pb-frontend
ports:
- protocol: TCP
port: 80
targetPort: 80
---
# Ingress (requires ingress controller, e.g., traefik)
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: bestofpb-ingress name: awards-ingress
namespace: awards
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod-cloudflare traefik.ingress.kubernetes.io/router.entrypoints: web, websecure
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true" traefik.ingress.kubernetes.io/router.tls: "true"
spec: spec:
ingressClassName: traefik
tls: tls:
- hosts: - hosts:
- awards.scottyah.com - awards.scottyah.com
secretName: awards-scottyah-com-tls secretName: awards-tls
rules: rules:
- host: awards.scottyah.com - host: awards.scottyah.com
http: http:
paths: paths:
# Route /api to backend
- path: /api
pathType: Prefix
backend:
service:
name: pb-backend
port:
number: 4000
# Route / to frontend
- path: / - path: /
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: pb-frontend name: awards-svc
port: port:
number: 80 number: 80

View File

@@ -4,12 +4,10 @@
set -e set -e
HARBOR_REGISTRY="harbor.scottyah.com/bestofpb"
get_latest_version() { get_latest_version() {
# Get the latest version tag from podman images for pb-backend or pb-frontend # Get the latest version tag from podman images for pb-backend or pb-frontend
podman images --format '{{.Repository}}:{{.Tag}}' | \ podman images --format '{{.Repository}}:{{.Tag}}' | \
grep -E '(pb-(backend|frontend)|harbor\.scottyah\.com/bestofpb/pb-(backend|frontend)):v[0-9]+\.[0-9]+\.[0-9]+' | \ grep -E 'pb-(backend|frontend):v[0-9]+\.[0-9]+\.[0-9]+' | \
sed -E 's/.*:(v[0-9]+\.[0-9]+\.[0-9]+)$/\1/' | \ sed -E 's/.*:(v[0-9]+\.[0-9]+\.[0-9]+)$/\1/' | \
sort -V | tail -n 1 sort -V | tail -n 1
} }
@@ -40,21 +38,15 @@ fi
# Build backend image # Build backend image
echo "Building backend image..." echo "Building backend image..."
podman build -t ${HARBOR_REGISTRY}/pb-backend:$VERSION ./backend podman build --platform=linux/arm64 -t pb-backend:$VERSION ./backend
podman tag ${HARBOR_REGISTRY}/pb-backend:$VERSION ${HARBOR_REGISTRY}/pb-backend:latest
echo "Building frontend image..." echo "Building frontend image..."
podman build -t ${HARBOR_REGISTRY}/pb-frontend:$VERSION ./frontend podman build --platform=linux/arm64 -t pb-frontend:$VERSION ./frontend
podman tag ${HARBOR_REGISTRY}/pb-frontend:$VERSION ${HARBOR_REGISTRY}/pb-frontend:latest
echo "\nBuild complete. Images:" echo "\nBuild complete. Images:"
echo " ${HARBOR_REGISTRY}/pb-backend:$VERSION" echo " pb-backend:$VERSION"
echo " ${HARBOR_REGISTRY}/pb-frontend:$VERSION" echo " pb-frontend:$VERSION"
echo "\nPushing images to Harbor..." echo "\nSaving images to tar files:"
podman push ${HARBOR_REGISTRY}/pb-backend:$VERSION podman image save -o frontend.tar pb-frontend:$VERSION
podman push ${HARBOR_REGISTRY}/pb-backend:latest podman image save -o backend.tar pb-backend:$VERSION
podman push ${HARBOR_REGISTRY}/pb-frontend:$VERSION
podman push ${HARBOR_REGISTRY}/pb-frontend:latest
echo "\nImages pushed successfully to Harbor registry"

58
ship.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
set -e
REGISTRY="harbor.scottyah.com/secure"
IMAGE_NAME="awards"
TAG="${1:-latest}"
echo "Building Docker image..."
docker build -t "${REGISTRY}/${IMAGE_NAME}:${TAG}" .
echo "Pushing image to registry..."
docker push "${REGISTRY}/${IMAGE_NAME}:${TAG}"
echo "Creating secrets from .env.local..."
if [ -f .env.local ]; then
# Create temporary directory for secret files
SECRET_TMPDIR=$(mktemp -d)
trap "rm -rf $SECRET_TMPDIR" EXIT
# Extract variables from .env.local into files
HAS_SECRET_VARS=false
while IFS= read -r line || [ -n "$line" ]; do
# Skip comments and empty lines
[[ "$line" =~ ^#.*$ ]] && continue
[[ -z "$line" ]] && continue
# Extract ADMIN_USERNAME
if [[ "$line" =~ ^ADMIN_USERNAME=(.*)$ ]]; then
echo -n "${BASH_REMATCH[1]}" > "$SECRET_TMPDIR/admin-username"
HAS_SECRET_VARS=true
fi
# Extract ADMIN_PASSWORD_HASH
if [[ "$line" =~ ^ADMIN_PASSWORD_HASH=(.*)$ ]]; then
echo -n "${BASH_REMATCH[1]}" > "$SECRET_TMPDIR/admin-password-hash"
HAS_SECRET_VARS=true
fi
done < .env.local
if [ "$HAS_SECRET_VARS" = true ]; then
kubectl delete secret awards-secret -n awards --ignore-not-found
kubectl create secret generic awards-secret -n awards --from-file="$SECRET_TMPDIR"
echo "Awards secret created from .env.local"
else
echo "No secret variables found in .env.local"
fi
else
echo "No .env.local file found, skipping secret creation"
fi
echo "Applying Kubernetes configuration..."
kubectl apply -f k8s.yaml
echo "Restarting deployment..."
kubectl rollout restart deployment/awards-dep -n awards
echo "Deployment complete!"
echo "Image: ${REGISTRY}/${IMAGE_NAME}:${TAG}"