Will Bradley 275c91f4e1 Allow users to edit the station itself when clicking a station (description, etc). Humanize refill/empty date/time formats. Remove last
updated line on the popup. On the select city page only show the add new city section and heading when logged in, adjust the login link
  based on logged-in status, and say click to update on each city button when logged in.
2025-07-16 13:16:16 -07:00

458 lines
14 KiB
JavaScript

const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const session = require('express-session');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const InstagramStrategy = require('passport-instagram').Strategy;
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcryptjs');
const bodyParser = require('body-parser');
const cors = require('cors');
const path = require('path');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
// Database setup
const db = new sqlite3.Database('./water_stations.db');
// Initialize database tables
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
google_id TEXT,
instagram_id TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS cities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users (id)
)`);
db.run(`CREATE TABLE IF NOT EXISTS water_stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
description TEXT,
city_id INTEGER NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (city_id) REFERENCES cities (id),
FOREIGN KEY (created_by) REFERENCES users (id)
)`);
db.run(`CREATE TABLE IF NOT EXISTS station_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
station_id INTEGER,
description TEXT,
last_refill_time DATETIME DEFAULT CURRENT_TIMESTAMP,
estimated_empty_time DATETIME,
updated_by INTEGER,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (station_id) REFERENCES water_stations (id),
FOREIGN KEY (updated_by) REFERENCES users (id)
)`);
// Create default city if none exists
db.get('SELECT COUNT(*) as count FROM cities', (err, row) => {
if (!err && row.count === 0) {
db.run('INSERT INTO cities (name, display_name, created_by) VALUES (?, ?, ?)',
['salem', 'Salem', null]);
}
});
// Add city_id column to existing water_stations if it doesn't exist
db.all("PRAGMA table_info(water_stations)", (err, columns) => {
if (!err) {
const hasCityId = columns.some(col => col.name === 'city_id');
if (!hasCityId) {
db.run('ALTER TABLE water_stations ADD COLUMN city_id INTEGER DEFAULT 1');
}
}
});
});
// Middleware
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: { secure: false } // Set to true in production with HTTPS
}));
app.use(passport.initialize());
app.use(passport.session());
// Passport configuration
passport.use(new LocalStrategy(
{ usernameField: 'username' },
async (username, password, done) => {
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
if (err) return done(err);
if (!user) return done(null, false);
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) return done(null, false);
return done(null, user);
});
}
));
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback"
}, (accessToken, refreshToken, profile, done) => {
db.get('SELECT * FROM users WHERE google_id = ?', [profile.id], (err, user) => {
if (err) return done(err);
if (user) {
return done(null, user);
} else {
db.run('INSERT INTO users (google_id, display_name, email) VALUES (?, ?, ?)',
[profile.id, profile.displayName, profile.emails[0].value],
function(err) {
if (err) return done(err);
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (err, user) => {
return done(err, user);
});
}
);
}
});
}));
passport.use(new InstagramStrategy({
clientID: process.env.INSTAGRAM_CLIENT_ID,
clientSecret: process.env.INSTAGRAM_CLIENT_SECRET,
callbackURL: "/auth/instagram/callback"
}, (accessToken, refreshToken, profile, done) => {
db.get('SELECT * FROM users WHERE instagram_id = ?', [profile.id], (err, user) => {
if (err) return done(err);
if (user) {
return done(null, user);
} else {
db.run('INSERT INTO users (instagram_id, display_name) VALUES (?, ?)',
[profile.id, profile.displayName],
function(err) {
if (err) return done(err);
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (err, user) => {
return done(err, user);
});
}
);
}
});
}));
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
db.get('SELECT * FROM users WHERE id = ?', [id], (err, user) => {
done(err, user);
});
});
// Routes
app.get('/', (req, res) => {
res.redirect('/city/salem');
});
app.get('/city-select', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'city-select.html'));
});
// Authentication routes
app.get('/auth/google', (req, res, next) => {
if (req.query.redirect) {
req.session.redirectUrl = req.query.redirect;
}
passport.authenticate('google', { scope: ['profile', 'email'] })(req, res, next);
});
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
const redirectUrl = req.session.redirectUrl || '/city/salem/dashboard';
delete req.session.redirectUrl;
res.redirect(redirectUrl);
}
);
app.get('/auth/instagram', (req, res, next) => {
if (req.query.redirect) {
req.session.redirectUrl = req.query.redirect;
}
passport.authenticate('instagram')(req, res, next);
});
app.get('/auth/instagram/callback',
passport.authenticate('instagram', { failureRedirect: '/login' }),
(req, res) => {
const redirectUrl = req.session.redirectUrl || '/city/salem/dashboard';
delete req.session.redirectUrl;
res.redirect(redirectUrl);
}
);
app.post('/auth/login', passport.authenticate('local'), (req, res) => {
res.json({ success: true, user: req.user });
});
app.post('/auth/register', async (req, res) => {
const { username, email, password } = req.body;
try {
const hashedPassword = await bcrypt.hash(password, 10);
db.run('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)',
[username, email, hashedPassword],
function(err) {
if (err) {
return res.status(400).json({ error: 'Username or email already exists' });
}
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (err, user) => {
if (err) return res.status(500).json({ error: 'Database error' });
req.login(user, (err) => {
if (err) return res.status(500).json({ error: 'Login error' });
res.json({ success: true, user: user });
});
});
}
);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
app.post('/auth/logout', (req, res) => {
req.logout(() => {
res.json({ success: true });
});
});
// API routes
app.get('/api/user', (req, res) => {
res.json({ user: req.user || null });
});
app.get('/api/cities', (req, res) => {
db.all('SELECT * FROM cities ORDER BY display_name', [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/cities', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, display_name } = req.body;
if (!name || !display_name) {
return res.status(400).json({ error: 'Name and display name are required' });
}
const normalizedName = name.toLowerCase().replace(/\s+/g, '-');
db.run('INSERT INTO cities (name, display_name, created_by) VALUES (?, ?, ?)',
[normalizedName, display_name, req.user.id],
function(err) {
if (err) {
if (err.code === 'SQLITE_CONSTRAINT') {
return res.status(400).json({ error: 'City already exists' });
}
return res.status(500).json({ error: 'Database error' });
}
res.json({ id: this.lastID, success: true });
}
);
});
app.get('/api/cities/:cityName/stations', (req, res) => {
const cityName = req.params.cityName;
const query = `
SELECT
ws.*,
u.username as created_by_name,
su.description as latest_description,
su.last_refill_time,
su.estimated_empty_time,
su.updated_at as last_updated,
u2.username as updated_by_name,
c.display_name as city_name
FROM water_stations ws
JOIN cities c ON ws.city_id = c.id
LEFT JOIN users u ON ws.created_by = u.id
LEFT JOIN (
SELECT station_id, description, last_refill_time, estimated_empty_time, updated_by, updated_at,
ROW_NUMBER() OVER (PARTITION BY station_id ORDER BY updated_at DESC) as rn
FROM station_updates
) su ON ws.id = su.station_id AND su.rn = 1
LEFT JOIN users u2 ON su.updated_by = u2.id
WHERE c.name = ?
`;
db.all(query, [cityName], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/cities/:cityName/stations', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const cityName = req.params.cityName;
const { name, latitude, longitude, description } = req.body;
// First, get the city ID
db.get('SELECT id FROM cities WHERE name = ?', [cityName], (err, city) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!city) return res.status(404).json({ error: 'City not found' });
db.run('INSERT INTO water_stations (name, latitude, longitude, description, city_id, created_by) VALUES (?, ?, ?, ?, ?, ?)',
[name, latitude, longitude, description, city.id, req.user.id],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ id: this.lastID, success: true });
}
);
});
});
app.get('/api/stations', (req, res) => {
const query = `
SELECT
ws.*,
u.username as created_by_name,
su.description as latest_description,
su.last_refill_time,
su.estimated_empty_time,
su.updated_at as last_updated,
u2.username as updated_by_name
FROM water_stations ws
LEFT JOIN users u ON ws.created_by = u.id
LEFT JOIN (
SELECT station_id, description, last_refill_time, estimated_empty_time, updated_by, updated_at,
ROW_NUMBER() OVER (PARTITION BY station_id ORDER BY updated_at DESC) as rn
FROM station_updates
) su ON ws.id = su.station_id AND su.rn = 1
LEFT JOIN users u2 ON su.updated_by = u2.id
`;
db.all(query, [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/stations', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, latitude, longitude, description, city_id } = req.body;
db.run('INSERT INTO water_stations (name, latitude, longitude, description, city_id, created_by) VALUES (?, ?, ?, ?, ?, ?)',
[name, latitude, longitude, description, city_id || 1, req.user.id],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ id: this.lastID, success: true });
}
);
});
app.post('/api/stations/:id/update', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { description, estimatedHours } = req.body;
const stationId = req.params.id;
const estimatedEmptyTime = new Date();
estimatedEmptyTime.setHours(estimatedEmptyTime.getHours() + parseInt(estimatedHours));
console.log("Updated by",req.user);
db.run('INSERT INTO station_updates (station_id, description, estimated_empty_time, updated_by) VALUES (?, ?, ?, ?)',
[stationId, description, estimatedEmptyTime.toISOString(), req.user.id],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ success: true });
}
);
});
app.put('/api/stations/:id', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, description } = req.body;
const stationId = req.params.id;
db.run('UPDATE water_stations SET name = ?, description = ? WHERE id = ?',
[name, description, stationId],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ success: true });
}
);
});
app.get('/dashboard', (req, res) => {
if (!req.user) {
return res.redirect('/login');
}
res.redirect('/city/salem/dashboard');
});
app.get('/city/:cityName', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/city/:cityName/dashboard', (req, res) => {
if (!req.user) {
return res.redirect('/login');
}
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
app.get('/login', (req, res) => {
if (req.query.redirect) {
req.session.redirectUrl = req.query.redirect;
}
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});