initial commit
This commit is contained in:
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# --- Build Stage ---
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
COPY . .
|
||||
|
||||
# --- Run Stage ---
|
||||
FROM node:20-alpine AS run
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "index.js"]
|
||||
BIN
backend/awards.db
Normal file
BIN
backend/awards.db
Normal file
Binary file not shown.
14
backend/backup-db.sh
Executable file
14
backend/backup-db.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
BACKUP_DIR="$(dirname "$0")/backups"
|
||||
DB_FILE="$(dirname "$0")/awards.db"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
BACKUP_FILE="$BACKUP_DIR/awards-$DATE.db"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp "$DB_FILE" "$BACKUP_FILE"
|
||||
|
||||
# Remove backups older than 7 days
|
||||
ls -1t "$BACKUP_DIR"/awards-*.db | tail -n +8 | xargs -r rm --
|
||||
|
||||
echo "Backup complete: $BACKUP_FILE"
|
||||
220
backend/index.js
Normal file
220
backend/index.js
Normal file
@@ -0,0 +1,220 @@
|
||||
const express = require('express');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = 4000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// SQLite setup
|
||||
const dbPath = path.join(__dirname, 'awards.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
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
|
||||
)`);
|
||||
});
|
||||
|
||||
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
||||
|
||||
// Basic auth middleware for management endpoints
|
||||
const basicAuth = (req, res, next) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Basic ')) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="Management"');
|
||||
return res.status(401).send('Authentication required.');
|
||||
}
|
||||
const credentials = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
|
||||
const [user, pass] = credentials;
|
||||
if (user === 'admin' && pass === 'password') return next();
|
||||
res.set('WWW-Authenticate', 'Basic realm="Management"');
|
||||
return res.status(401).send('Invalid credentials.');
|
||||
};
|
||||
|
||||
// Get all awards
|
||||
app.get('/awards', (req, res) => {
|
||||
db.all('SELECT * FROM awards ORDER BY submitted_date DESC', [], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
// Parse emoji_tally JSON for each row
|
||||
const awards = rows.map(row => ({
|
||||
...row,
|
||||
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
|
||||
}));
|
||||
res.json(awards);
|
||||
});
|
||||
});
|
||||
|
||||
// Add a new award
|
||||
app.post('/awards', async (req, res) => {
|
||||
const { category, address, submitted_by } = req.body;
|
||||
if (!category || !address) {
|
||||
return res.status(400).json({ error: 'Category and address are required.' });
|
||||
}
|
||||
let lat = null, lng = null;
|
||||
let isPB = false;
|
||||
try {
|
||||
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)' } });
|
||||
const data = await resp.json();
|
||||
if (data && data[0]) {
|
||||
lat = parseFloat(data[0].lat);
|
||||
lng = parseFloat(data[0].lon);
|
||||
// Check if address is in Pacific Beach
|
||||
const displayName = data[0].display_name || '';
|
||||
const addressObj = data[0].address || {};
|
||||
// Accept if display_name contains 'Pacific Beach' or zip is 92109
|
||||
if (
|
||||
displayName.toLowerCase().includes('pacific beach') ||
|
||||
addressObj.suburb === 'Pacific Beach' ||
|
||||
addressObj.neighbourhood === 'Pacific Beach' ||
|
||||
addressObj.city === 'San Diego' && addressObj.postcode === '92109' ||
|
||||
addressObj.postcode === '92109'
|
||||
) {
|
||||
isPB = true;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore geocode errors */ }
|
||||
if (!isPB) {
|
||||
return res.status(400).json({ error: 'Address must be in Pacific Beach (92109, San Diego, CA).' });
|
||||
}
|
||||
db.run(
|
||||
'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)',
|
||||
[category, address, submitted_by || null, '{}', lat, lng],
|
||||
function (err) {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.get('SELECT * FROM awards WHERE id = ?', [this.lastID], (err, row) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// 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(
|
||||
'UPDATE awards SET emoji_tally = ? WHERE id = ?',
|
||||
[JSON.stringify(emojiTally), id],
|
||||
function (err) {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json({ id, emoji_tally: emojiTally });
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 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) : {};
|
||||
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) return res.status(500).json({ error: err.message });
|
||||
res.json({ id, emoji_tally: emojiTally });
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res.json({ id, emoji_tally: emojiTally });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Approve an award
|
||||
app.patch('/awards/:id/approve', basicAuth, (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.run(
|
||||
'UPDATE awards SET approved_date = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[id],
|
||||
function (err) {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Award not found.' });
|
||||
res.json({ id, approved_date: new Date().toISOString() });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Get all pending (unapproved) awards
|
||||
app.get('/awards/pending', basicAuth, (req, res) => {
|
||||
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 });
|
||||
const awards = rows.map(row => ({
|
||||
...row,
|
||||
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
|
||||
}));
|
||||
res.json(awards);
|
||||
});
|
||||
});
|
||||
|
||||
// Reject (delete) a nomination
|
||||
app.delete('/awards/:id', basicAuth, (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.run('DELETE FROM awards WHERE id = ?', [id], function (err) {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Award not found.' });
|
||||
res.json({ id, deleted: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Get top 5 awards approved, sorted by emoji tally (descending)
|
||||
app.get('/awards/top', (req, res) => {
|
||||
db.all(
|
||||
`SELECT * FROM awards WHERE approved_date IS NOT NULL`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
// Parse emoji_tally and calculate total emojis for sorting
|
||||
const awards = rows.map(row => {
|
||||
const emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
|
||||
const emojiTotal = Object.values(emojiTally).reduce((sum, n) => sum + Number(n), 0);
|
||||
return { ...row, emoji_tally: emojiTally, emojiTotal };
|
||||
});
|
||||
// Sort by emojiTotal descending, then by most recently approved
|
||||
awards.sort((a, b) => {
|
||||
if (b.emojiTotal !== a.emojiTotal) return b.emojiTotal - a.emojiTotal;
|
||||
return new Date(b.approved_date) - new Date(a.approved_date);
|
||||
});
|
||||
res.json(awards.slice(0, 5));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Backend server running on http://localhost:${PORT}`);
|
||||
});
|
||||
16
backend/package.json
Normal file
16
backend/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "pb-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"seed": "node seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"sqlite3": "^5.1.7",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
89
backend/seed.js
Normal file
89
backend/seed.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, 'awards.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
const awardTemplates = [
|
||||
"Most Dog Friendly", "Biggest Cactus", "Most Likely to be a Jungle", "Best Tacos", "Most Colorful House",
|
||||
"Best Surf Spot", "Most Palm Trees", "Best Sunset View", "Most Beach Vibes", "Best Coffee Shop",
|
||||
"Most Instagrammable", "Best Pizza", "Most Relaxing", "Best Ice Cream", "Most Artistic",
|
||||
"Best Seafood", "Most Tropical", "Best Breakfast", "Most Peaceful", "Best Mexican Food",
|
||||
"Most Oceanfront", "Best Smoothies", "Most Lively", "Best Burgers", "Most Zen",
|
||||
"Best Sushi", "Most Vibrant", "Best Acai Bowl", "Most Welcoming", "Best Thai Food",
|
||||
"Most Scenic", "Best Juice Bar", "Most Energetic", "Best Italian", "Most Serene",
|
||||
"Best Fish Tacos", "Most Dynamic", "Best Smoothie Bowl", "Most Friendly", "Best Indian Food",
|
||||
"Most Picturesque", "Best Tea House", "Most Exciting", "Best Greek Food", "Most Tranquil",
|
||||
"Best Burritos", "Most Active", "Best Poke Bowl", "Most Hospitable", "Best Mediterranean"
|
||||
];
|
||||
|
||||
const realPBAddresses = [
|
||||
"4500 Ocean Blvd, San Diego, CA 92109",
|
||||
"710 Garnet Ave, San Diego, CA 92109",
|
||||
"976 Felspar St, San Diego, CA 92109",
|
||||
"4325 Mission Blvd, San Diego, CA 92109",
|
||||
"5010 Cass St, San Diego, CA 92109",
|
||||
"744 Ventura Pl, San Diego, CA 92109",
|
||||
"4150 Mission Blvd, San Diego, CA 92109",
|
||||
"909 Garnet Ave, San Diego, CA 92109",
|
||||
"3704 Mission Blvd, San Diego, CA 92109",
|
||||
"1860 Grand Ave, San Diego, CA 92109"
|
||||
];
|
||||
|
||||
const realPBCoords = [
|
||||
{ lat: 32.7977, lng: -117.2551 }, // 4500 Ocean Blvd
|
||||
{ lat: 32.7979, lng: -117.2542 }, // 710 Garnet Ave
|
||||
{ lat: 32.7975, lng: -117.2520 }, // 976 Felspar St
|
||||
{ lat: 32.7972, lng: -117.2525 }, // 4325 Mission Blvd
|
||||
{ lat: 32.8002, lng: -117.2527 }, // 5010 Cass St
|
||||
{ lat: 32.7701, lng: -117.2523 }, // 744 Ventura Pl
|
||||
{ lat: 32.7970, lng: -117.2540 }, // 4150 Mission Blvd
|
||||
{ lat: 32.7978, lng: -117.2530 }, // 909 Garnet Ave
|
||||
{ lat: 32.7973, lng: -117.2522 }, // 3704 Mission Blvd
|
||||
{ lat: 32.8010, lng: -117.2420 } // 1860 Grand Ave
|
||||
];
|
||||
|
||||
function seedAwards() {
|
||||
db.serialize(() => {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS awards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
emoji_tally TEXT DEFAULT '{}',
|
||||
submitted_by VARCHAR(255),
|
||||
submitted_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
approved_date DATETIME,
|
||||
lat REAL,
|
||||
lng REAL
|
||||
)`);
|
||||
|
||||
let remaining = awardTemplates.length;
|
||||
awardTemplates.forEach((title, index) => {
|
||||
const address = realPBAddresses[index % realPBAddresses.length];
|
||||
const coords = realPBCoords[index % realPBCoords.length];
|
||||
db.get('SELECT * FROM awards WHERE category = ? AND address = ?', [title, address], (err, row) => {
|
||||
if (err) console.error(err);
|
||||
if (!row) {
|
||||
const approvedDate = title.startsWith('Best') ? null : 'CURRENT_TIMESTAMP';
|
||||
const sql = approvedDate
|
||||
? 'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, approved_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)'
|
||||
: 'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, approved_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL, ?, ?)';
|
||||
db.run(
|
||||
sql,
|
||||
[title, address, null, '{}', coords.lat, coords.lng],
|
||||
(err) => {
|
||||
if (err) console.error(err);
|
||||
else console.log(`Inserted: ${title}`);
|
||||
if (--remaining === 0) db.close();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if (--remaining === 0) db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
if (awardTemplates.length === 0) db.close();
|
||||
});
|
||||
}
|
||||
|
||||
seedAwards();
|
||||
Reference in New Issue
Block a user