initial commit

This commit is contained in:
Scott Hatlen
2025-11-05 09:06:54 -08:00
commit e3941089e1
27 changed files with 31423 additions and 0 deletions

13
backend/Dockerfile Normal file
View 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

Binary file not shown.

14
backend/backup-db.sh Executable file
View 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
View 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
View 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
View 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();