diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8a607c8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,143 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Running the Application +```bash +# Install dependencies +npm install + +# Start the server (production mode) +npm start + +# Start with auto-reload (development mode) +npm run dev + +# Development with CSS watching (recommended for frontend work) +npm run dev-with-css +``` + +The application runs on port 3000 by default. Visit http://localhost:3000 to view the website. + +### CSS Development +CSS is generated from SCSS and should NOT be committed to git. +```bash +# Build CSS once (compressed for production) +npm run build-css + +# Build CSS with source maps (for development) +npm run build-css:dev + +# Watch SCSS files and auto-compile changes +npm run watch-css +``` + +### Testing +```bash +# Run all tests +npm test + +# Run tests with coverage report +npm run test:coverage +``` + +### Environment Setup +Before running the application, you must configure environment variables: +```bash +cp .env.example .env +# Edit .env to add your MapBox token and admin password +``` + +Required environment variables: +- `MAPBOX_ACCESS_TOKEN`: MapBox API token for geocoding (get free token at https://account.mapbox.com/access-tokens/) +- `ADMIN_PASSWORD`: Password for admin panel access at /admin +- `PORT`: Server port (default: 3000) + +## Architecture Overview + +### Backend (Node.js/Express) +- **server.js**: Main Express server with modular route architecture + - Uses two SQLite databases: `icewatch.db` (locations) and `profanity.db` (content moderation) + - Automatic cleanup of reports older than 48 hours via node-cron + - Bearer token authentication for admin endpoints + - Environment variable configuration via dotenv + +### Route Architecture +Routes are organized as factory functions accepting dependencies: + +- **routes/config.js**: Public API configuration endpoints +- **routes/locations.js**: Location submission and retrieval with profanity filtering +- **routes/admin.js**: Admin panel functionality with authentication middleware + +### Database Schema +**Main Database (`icewatch.db`)**: +```sql +CREATE TABLE locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + latitude REAL, + longitude REAL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + description TEXT, + persistent INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +**Profanity Database (`profanity.db`)**: +Managed by the `ProfanityFilter` class for content moderation. + +### Frontend (Vanilla JavaScript) +Multiple map implementations for flexibility: + +- **public/app.js**: Main implementation using Leaflet.js + - Auto-detects available geocoding services (MapBox preferred, Nominatim fallback) + +- **public/app-mapbox.js**: MapBox GL JS implementation for enhanced features + +- **public/app-google.js**: Google Maps implementation (alternative) + +- **public/admin.js**: Admin panel functionality + - Location management (view, edit, delete) + - Persistent location toggle + - Profanity word management + +- **public/utils.js**: Shared utilities across implementations + +### API Endpoints + +Public endpoints: +- `GET /api/config`: Returns MapBox token for frontend geocoding +- `GET /api/locations`: Active locations (< 48 hours old or persistent) +- `POST /api/locations`: Submit new location report (with profanity filtering) + +Admin endpoints (require Bearer token): +- `POST /api/admin/login`: Authenticate and receive token +- `GET /api/admin/locations`: All locations including expired +- `PUT /api/admin/locations/:id`: Update location details +- `PATCH /api/admin/locations/:id/persistent`: Toggle persistent status +- `DELETE /api/admin/locations/:id`: Delete location +- Profanity management: `/api/admin/profanity-words` (GET, POST, PUT, DELETE) + +### SCSS Organization +SCSS files are in `src/scss/`: +- `main.scss`: Entry point importing all other files +- `_variables.scss`: Theme colors and configuration +- `_mixins.scss`: Reusable style patterns +- `pages/`: Page-specific styles (home, admin, privacy) +- `components/`: Component styles (navbar, map, cards, forms) + +### Key Design Patterns + +1. **Modular Route Architecture**: Routes accept dependencies as parameters for testability +2. **Dual Database Design**: Separate databases for application data and content moderation +3. **Graceful Degradation**: Fallback geocoding providers and error handling +4. **Automated Maintenance**: Cron-based cleanup of expired reports +5. **Security**: Server-side content filtering, environment-based configuration + +### Deployment +- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh` +- Caddy reverse proxy configuration in `scripts/Caddyfile` +- Systemd service files for process management \ No newline at end of file diff --git a/models/Location.js b/models/Location.js new file mode 100644 index 0000000..8d5c5d7 --- /dev/null +++ b/models/Location.js @@ -0,0 +1,131 @@ +class Location { + constructor(db) { + this.db = db; + } + + async getActive(hoursThreshold = 48) { + const cutoffTime = new Date(Date.now() - hoursThreshold * 60 * 60 * 1000).toISOString(); + return new Promise((resolve, reject) => { + this.db.all( + 'SELECT * FROM locations WHERE created_at > ? OR persistent = 1 ORDER BY created_at DESC', + [cutoffTime], + (err, rows) => { + if (err) return reject(err); + resolve(rows); + } + ); + }); + } + + async getAll() { + return new Promise((resolve, reject) => { + this.db.all( + 'SELECT id, address, description, latitude, longitude, persistent, created_at FROM locations ORDER BY created_at DESC', + [], + (err, rows) => { + if (err) return reject(err); + resolve(rows); + } + ); + }); + } + + async create(location) { + const { address, latitude, longitude, description } = location; + return new Promise((resolve, reject) => { + this.db.run( + 'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)', + [address, latitude, longitude, description], + function(err) { + if (err) return reject(err); + resolve({ id: this.lastID, ...location }); + } + ); + }); + } + + async update(id, location) { + const { address, latitude, longitude, description } = location; + return new Promise((resolve, reject) => { + this.db.run( + 'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?', + [address, latitude, longitude, description, id], + function(err) { + if (err) return reject(err); + resolve({ changes: this.changes }); + } + ); + }); + } + + async togglePersistent(id, persistent) { + return new Promise((resolve, reject) => { + this.db.run( + 'UPDATE locations SET persistent = ? WHERE id = ?', + [persistent ? 1 : 0, id], + function(err) { + if (err) return reject(err); + resolve({ changes: this.changes }); + } + ); + }); + } + + async delete(id) { + return new Promise((resolve, reject) => { + this.db.run( + 'DELETE FROM locations WHERE id = ?', + [id], + function(err) { + if (err) return reject(err); + resolve({ changes: this.changes }); + } + ); + }); + } + + async cleanupExpired(hoursThreshold = 48) { + const cutoffTime = new Date(Date.now() - hoursThreshold * 60 * 60 * 1000).toISOString(); + return new Promise((resolve, reject) => { + this.db.run( + 'DELETE FROM locations WHERE created_at < ? AND persistent = 0', + [cutoffTime], + function(err) { + if (err) return reject(err); + resolve({ changes: this.changes }); + } + ); + }); + } + + async initializeTable() { + return new Promise((resolve, reject) => { + this.db.serialize(() => { + this.db.run(` + CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + latitude REAL, + longitude REAL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { + if (err) return reject(err); + + this.db.run(` + ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0 + `, (err) => { + if (err && !err.message.includes('duplicate column name')) { + return reject(err); + } + resolve(); + }); + }); + }); + }); + } +} + +module.exports = Location; \ No newline at end of file diff --git a/models/ProfanityWord.js b/models/ProfanityWord.js new file mode 100644 index 0000000..76ed356 --- /dev/null +++ b/models/ProfanityWord.js @@ -0,0 +1,96 @@ +class ProfanityWord { + constructor(db) { + this.db = db; + } + + async getAll() { + return new Promise((resolve, reject) => { + this.db.all( + 'SELECT id, word, severity, category, created_at, created_by FROM profanity_words ORDER BY created_at DESC', + [], + (err, rows) => { + if (err) return reject(err); + resolve(rows); + } + ); + }); + } + + async loadWords() { + return new Promise((resolve, reject) => { + this.db.all( + 'SELECT word, severity, category FROM profanity_words', + [], + (err, rows) => { + if (err) return reject(err); + resolve(rows); + } + ); + }); + } + + async create(word, severity, category, createdBy = 'admin') { + return new Promise((resolve, reject) => { + this.db.run( + 'INSERT INTO profanity_words (word, severity, category, created_by) VALUES (?, ?, ?, ?)', + [word.toLowerCase(), severity, category, createdBy], + function(err) { + if (err) return reject(err); + resolve({ + id: this.lastID, + word: word.toLowerCase(), + severity, + category, + created_by: createdBy + }); + } + ); + }); + } + + async update(id, word, severity, category) { + return new Promise((resolve, reject) => { + this.db.run( + 'UPDATE profanity_words SET word = ?, severity = ?, category = ? WHERE id = ?', + [word.toLowerCase(), severity, category, id], + function(err) { + if (err) return reject(err); + resolve({ changes: this.changes }); + } + ); + }); + } + + async delete(id) { + return new Promise((resolve, reject) => { + this.db.run( + 'DELETE FROM profanity_words WHERE id = ?', + [id], + function(err) { + if (err) return reject(err); + resolve({ changes: this.changes }); + } + ); + }); + } + + async initializeTable() { + return new Promise((resolve, reject) => { + this.db.run(` + CREATE TABLE IF NOT EXISTS profanity_words ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + word TEXT NOT NULL UNIQUE, + severity TEXT NOT NULL, + category TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by TEXT DEFAULT 'system' + ) + `, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + } +} + +module.exports = ProfanityWord; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1e1742b..852b6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "great-lakes-ice-report", "version": "1.0.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "cors": "^2.8.5", diff --git a/public/app-mapbox-refactored.js b/public/app-mapbox-refactored.js new file mode 100644 index 0000000..3726a93 --- /dev/null +++ b/public/app-mapbox-refactored.js @@ -0,0 +1,350 @@ +/** + * MapBox implementation with table view and theme toggle using MapBase + */ +class MapBoxApp extends MapBase { + constructor() { + super(); + this.config = { hasMapbox: false, mapboxAccessToken: null }; + this.currentView = 'map'; // 'map' or 'table' + this.initializeConfig(); + this.initializeMapBoxFeatures(); + } + + /** + * Initialize API configuration + */ + async initializeConfig() { + try { + const configResponse = await fetch('/api/config'); + this.config = await configResponse.json(); + console.log('πŸ”§ API Configuration:', this.config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback'); + } catch (error) { + console.warn('Failed to load API configuration, using fallback'); + } + + this.initializeMap(); + } + + /** + * Initialize MapBox-specific features + */ + initializeMapBoxFeatures() { + this.initializeViewToggle(); + this.initializeThemeToggle(); + } + + /** + * Initialize the Leaflet map + */ + initializeMap() { + this.map = L.map('map').setView([42.9634, -85.6681], 10); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + }).addTo(this.map); + } + + /** + * Initialize view toggle functionality + */ + initializeViewToggle() { + const mapViewBtn = document.getElementById('map-view-btn'); + const tableViewBtn = document.getElementById('table-view-btn'); + + if (mapViewBtn && tableViewBtn) { + mapViewBtn.addEventListener('click', () => this.switchView('map')); + tableViewBtn.addEventListener('click', () => this.switchView('table')); + } + } + + /** + * Initialize theme toggle functionality + */ + initializeThemeToggle() { + const themeToggle = document.getElementById('theme-toggle'); + if (themeToggle) { + themeToggle.addEventListener('click', () => this.toggleTheme()); + } + + // Apply saved theme on load + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + document.body.dataset.theme = savedTheme; + } + } + + /** + * Toggle between light and dark theme + */ + toggleTheme() { + const currentTheme = document.body.dataset.theme || 'light'; + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; + + document.body.dataset.theme = newTheme; + localStorage.setItem('theme', newTheme); + } + + /** + * Switch between map and table view + */ + switchView(viewType) { + this.currentView = viewType; + + const mapView = document.getElementById('map-view'); + const tableView = document.getElementById('table-view'); + const mapViewBtn = document.getElementById('map-view-btn'); + const tableViewBtn = document.getElementById('table-view-btn'); + + if (viewType === 'map') { + mapView.style.display = 'block'; + tableView.style.display = 'none'; + mapViewBtn.classList.add('active'); + tableViewBtn.classList.remove('active'); + + // Invalidate map size after showing + setTimeout(() => this.map.invalidateSize(), 100); + } else { + mapView.style.display = 'none'; + tableView.style.display = 'block'; + mapViewBtn.classList.remove('active'); + tableViewBtn.classList.add('active'); + } + } + + /** + * Create custom marker for location + */ + createMarker(location) { + const isPersistent = !!location.persistent; + const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''; + const persistentText = isPersistent ? '
πŸ“Œ Persistent Report' : ''; + + const customIcon = this.createCustomIcon(isPersistent); + + const marker = L.marker([location.latitude, location.longitude], { + icon: customIcon + }) + .addTo(this.map) + .bindPopup(`${location.address}
${location.description || 'No additional details'}
Reported ${timeAgo}${persistentText}`); + + return marker; + } + + /** + * Create custom icon for markers + */ + createCustomIcon(isPersistent = false) { + const iconColor = isPersistent ? '#28a745' : '#dc3545'; + const iconSymbol = isPersistent ? 'πŸ”’' : '⚠️'; + + return L.divIcon({ + className: 'custom-marker', + html: ` +
${iconSymbol}
+ `, + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }); + } + + /** + * Display locations (override to handle table view) + */ + displayLocations(locations) { + super.displayLocations(locations); + + // Update table view if currently active + if (this.currentView === 'table') { + this.renderTable(locations); + } + } + + /** + * Render table view + */ + renderTable(locations) { + const reportsTableBody = document.getElementById('reports-tbody'); + const tableLocationCount = document.getElementById('table-location-count'); + + if (!reportsTableBody || !locations || locations.length === 0) { + if (reportsTableBody) { + reportsTableBody.innerHTML = 'No active reports'; + } + if (tableLocationCount) { + tableLocationCount.textContent = '0 active reports'; + } + return; + } + + // Sort by most recent first + const sortedLocations = [...locations].sort((a, b) => + new Date(b.created_at) - new Date(a.created_at) + ); + + const tableHTML = sortedLocations.map(location => { + const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''; + const timeRemaining = this.getTimeRemaining(location.created_at, location.persistent); + const remainingClass = this.getRemainingClass(location.created_at, location.persistent); + const reportedTime = new Date(location.created_at).toLocaleString(); + + return ` + + ${location.address} + + ${location.description || 'No additional details'} + + ${timeAgo} + ${timeRemaining} + + `; + }).join(''); + + reportsTableBody.innerHTML = tableHTML; + tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`; + } + + /** + * Get time remaining for a report + */ + getTimeRemaining(createdAt, isPersistent) { + if (isPersistent) { + return '♾️ Persistent'; + } + + const created = new Date(createdAt); + const now = new Date(); + const elapsed = now - created; + const remaining = (48 * 60 * 60 * 1000) - elapsed; // 48 hours in ms + + if (remaining <= 0) { + return '⏰ Expired'; + } + + const hours = Math.floor(remaining / (60 * 60 * 1000)); + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); + + if (hours >= 24) { + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return `${days}d ${remainingHours}h`; + } + + return `${hours}h ${minutes}m`; + } + + /** + * Get CSS class for time remaining + */ + getRemainingClass(createdAt, isPersistent) { + if (isPersistent) { + return 'persistent'; + } + + const created = new Date(createdAt); + const now = new Date(); + const elapsed = now - created; + const remaining = (48 * 60 * 60 * 1000) - elapsed; + + if (remaining <= 0) { + return 'expired'; + } else if (remaining < 6 * 60 * 60 * 1000) { // Less than 6 hours + return 'expiring-soon'; + } else if (remaining < 24 * 60 * 60 * 1000) { // Less than 24 hours + return 'expiring-today'; + } + + return 'fresh'; + } + + /** + * Search for addresses using MapBox or Nominatim fallback + */ + async searchAddress(query) { + try { + let results = []; + + // Try MapBox first if available + if (this.config.hasMapbox && this.config.mapboxAccessToken) { + try { + results = await this.searchMapBox(query); + if (results.length > 0) { + this.showAutocomplete(results); + return; + } + } catch (error) { + console.warn('MapBox search failed, falling back to Nominatim:', error); + } + } + + // Fallback to Nominatim + results = await this.searchNominatim(query); + this.showAutocomplete(results); + + } catch (error) { + console.error('Error searching addresses:', error); + this.showAutocomplete([]); + } + } + + /** + * Search using MapBox API + */ + async searchMapBox(query) { + const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${this.config.mapboxAccessToken}&country=us&proximity=-85.6681,42.9634&limit=5`); + const data = await response.json(); + + if (data.features && data.features.length > 0) { + return data.features.map(feature => ({ + display_name: feature.place_name, + lat: feature.center[1], + lon: feature.center[0] + })); + } + + return []; + } + + /** + * Search using Nominatim as fallback + */ + async searchNominatim(query) { + const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(query + ', Michigan')}`); + const data = await response.json(); + + if (data && data.length > 0) { + return data.slice(0, 5); + } + + return []; + } + + /** + * Handle submission errors (override to handle profanity rejection) + */ + handleSubmitError(data) { + if (data.error === 'Submission rejected' && data.message) { + // Enhanced profanity rejection handling + this.showMessage(data.message, 'error', 10000); + } else { + super.handleSubmitError(data); + } + } +} + +// Initialize the app when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new MapBoxApp(); +}); \ No newline at end of file diff --git a/public/app-refactored.js b/public/app-refactored.js new file mode 100644 index 0000000..2aa5e36 --- /dev/null +++ b/public/app-refactored.js @@ -0,0 +1,221 @@ +/** + * Nominatim-only implementation using MapBase + */ +class NominatimMapApp extends MapBase { + constructor() { + super(); + this.initializeMap(); + } + + /** + * Initialize the Leaflet map + */ + initializeMap() { + this.map = L.map('map').setView([42.9634, -85.6681], 10); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + }).addTo(this.map); + } + + /** + * Create custom marker for location + */ + createMarker(location) { + const isPersistent = !!location.persistent; + const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''; + const persistentText = isPersistent ? '
πŸ“Œ Persistent Report' : ''; + + const customIcon = this.createCustomIcon(isPersistent); + + const marker = L.marker([location.latitude, location.longitude], { + icon: customIcon + }) + .addTo(this.map) + .bindPopup(`${location.address}
${location.description || 'No additional details'}
Reported ${timeAgo}${persistentText}`); + + return marker; + } + + /** + * Create custom icon for markers + */ + createCustomIcon(isPersistent = false) { + const iconColor = isPersistent ? '#28a745' : '#dc3545'; // Green for persistent, red for temporary + const iconSymbol = isPersistent ? 'πŸ”’' : '⚠️'; // Lock for persistent, warning for temporary + + return L.divIcon({ + className: 'custom-marker', + html: ` +
${iconSymbol}
+ `, + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }); + } + + /** + * Search for addresses using Nominatim + */ + async searchAddress(query) { + try { + // Generate multiple search variations for better results + const searches = [ + query, // Original query + query.replace(' & ', ' and '), // Replace & with 'and' + query.replace(' and ', ' & '), // Replace 'and' with & + query.replace(' at ', ' & '), // Replace 'at' with & + `${query}, Michigan`, // Add Michigan if not present + `${query}, MI`, // Add MI if not present + ]; + + // Add street type variations + const streetVariations = []; + searches.forEach(search => { + streetVariations.push(search); + streetVariations.push(search.replace(' St ', ' Street ')); + streetVariations.push(search.replace(' Street ', ' St ')); + streetVariations.push(search.replace(' Ave ', ' Avenue ')); + streetVariations.push(search.replace(' Avenue ', ' Ave ')); + streetVariations.push(search.replace(' Rd ', ' Road ')); + streetVariations.push(search.replace(' Road ', ' Rd ')); + streetVariations.push(search.replace(' Blvd ', ' Boulevard ')); + streetVariations.push(search.replace(' Boulevard ', ' Blvd ')); + }); + + // Remove duplicates + const uniqueSearches = [...new Set(streetVariations)]; + + // Try each search variation until we get results + for (const searchQuery of uniqueSearches) { + try { + const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(searchQuery)}`); + const data = await response.json(); + + if (data && data.length > 0) { + // Prefer results that are in Michigan/Grand Rapids + const michiganResults = data.filter(item => + item.display_name.toLowerCase().includes('michigan') || + item.display_name.toLowerCase().includes('grand rapids') || + item.display_name.toLowerCase().includes(', mi') + ); + + const results = michiganResults.length > 0 ? michiganResults : data; + this.showAutocomplete(results.slice(0, 5)); + return; + } + } catch (error) { + console.warn(`Geocoding failed for: "${searchQuery}"`, error); + } + } + + // If no results found, show empty autocomplete + this.showAutocomplete([]); + + } catch (error) { + console.error('Error searching addresses:', error); + this.showAutocomplete([]); + } + } + + /** + * Handle form submission with enhanced geocoding + */ + async handleSubmit() { + const address = this.addressInput.value.trim(); + const description = this.descriptionInput.value.trim(); + + if (!address) { + this.showMessage('Please enter an address', 'error'); + return; + } + + // If coordinates are not set, try to geocode the address + let lat = parseFloat(this.addressInput.dataset.lat); + let lon = parseFloat(this.addressInput.dataset.lon); + + if (!lat || !lon || isNaN(lat) || isNaN(lon)) { + // Try to geocode the current address value + const geocodeResult = await this.geocodeAddress(address); + if (geocodeResult) { + lat = parseFloat(geocodeResult.lat); + lon = parseFloat(geocodeResult.lon); + } else { + this.showMessage('Please select a valid address from the suggestions', 'error'); + return; + } + } + + // Disable submit button + this.submitButton.disabled = true; + this.submitButton.innerHTML = ' Submitting...'; + + try { + const response = await fetch('/api/locations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + address, + latitude: lat, + longitude: lon, + description + }) + }); + + const data = await response.json(); + + if (response.ok) { + this.showMessage('Location reported successfully!', 'success'); + this.resetForm(); + this.refreshLocations(); + } else { + this.handleSubmitError(data); + } + } catch (error) { + console.error('Error submitting location:', error); + this.showMessage('Error submitting location. Please try again.', 'error'); + } finally { + this.submitButton.disabled = false; + this.submitButton.innerHTML = ' Report Ice'; + } + } + + /** + * Geocode an address using Nominatim + */ + async geocodeAddress(address) { + try { + const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(address + ', Michigan')}`); + const data = await response.json(); + + if (data && data.length > 0) { + return data[0]; + } + + return null; + } catch (error) { + console.error('Error geocoding address:', error); + return null; + } + } +} + +// Initialize the app when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new NominatimMapApp(); +}); \ No newline at end of file diff --git a/public/map-base.js b/public/map-base.js new file mode 100644 index 0000000..96b7fff --- /dev/null +++ b/public/map-base.js @@ -0,0 +1,399 @@ +/** + * Base class for all map implementations + * Provides common functionality for map display, location management, and UI interactions + */ +class MapBase { + constructor() { + // Map instance (to be initialized by subclasses) + this.map = null; + this.currentMarkers = []; + + // Autocomplete state + this.autocompleteTimeout = null; + this.selectedIndex = -1; + this.autocompleteResults = []; + + // Update interval + this.updateInterval = null; + + // DOM elements + this.addressInput = document.getElementById('address'); + this.descriptionInput = document.getElementById('description'); + this.submitButton = document.querySelector('.submit-btn'); + this.messageElement = document.getElementById('message'); + this.locationCountElement = document.getElementById('location-count'); + this.lastUpdateElement = document.getElementById('last-update'); + this.autocompleteContainer = document.getElementById('autocomplete'); + this.locationsContainer = document.querySelector('.locations-container'); + + // Initialize event listeners + this.initializeEventListeners(); + + // Start automatic refresh + this.startAutoRefresh(); + } + + /** + * Initialize common event listeners + */ + initializeEventListeners() { + // Form submission + document.getElementById('location-form').addEventListener('submit', (e) => { + e.preventDefault(); + this.handleSubmit(); + }); + + // Address input with autocomplete + this.addressInput.addEventListener('input', (e) => { + this.handleAddressInput(e); + }); + + // Keyboard navigation for autocomplete + this.addressInput.addEventListener('keydown', (e) => { + this.handleAutocompleteKeydown(e); + }); + + // Click outside to close autocomplete + document.addEventListener('click', (e) => { + if (!e.target.closest('.search-container')) { + this.hideAutocomplete(); + } + }); + + // Refresh button + const refreshButton = document.getElementById('refresh-button'); + if (refreshButton) { + refreshButton.addEventListener('click', () => { + this.refreshLocations(); + }); + } + } + + /** + * Handle address input for autocomplete + */ + handleAddressInput(e) { + clearTimeout(this.autocompleteTimeout); + const query = e.target.value.trim(); + + if (query.length < 3) { + this.hideAutocomplete(); + return; + } + + this.autocompleteTimeout = setTimeout(() => { + this.searchAddress(query); + }, 300); + } + + /** + * Handle keyboard navigation in autocomplete + */ + handleAutocompleteKeydown(e) { + if (!this.autocompleteContainer.classList.contains('show')) return; + + switch(e.key) { + case 'ArrowDown': + e.preventDefault(); + this.selectedIndex = Math.min(this.selectedIndex + 1, this.autocompleteResults.length - 1); + this.updateSelection(); + break; + case 'ArrowUp': + e.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, -1); + this.updateSelection(); + break; + case 'Enter': + e.preventDefault(); + if (this.selectedIndex >= 0) { + this.selectAddress(this.selectedIndex); + } + break; + case 'Escape': + this.hideAutocomplete(); + break; + } + } + + /** + * Search for addresses - to be implemented by subclasses + */ + async searchAddress(query) { + // To be implemented by subclasses + throw new Error('searchAddress must be implemented by subclass'); + } + + /** + * Show autocomplete results + */ + showAutocomplete(results) { + this.autocompleteResults = results; + this.selectedIndex = -1; + + if (results.length === 0) { + this.hideAutocomplete(); + return; + } + + this.autocompleteContainer.innerHTML = results.map((result, index) => ` +
+ ${result.display_name || result.formatted_address || result.name} +
+ `).join(''); + + // Add click handlers + this.autocompleteContainer.querySelectorAll('.autocomplete-item').forEach(item => { + item.addEventListener('click', () => { + this.selectAddress(parseInt(item.dataset.index)); + }); + }); + + this.autocompleteContainer.classList.add('show'); + } + + /** + * Hide autocomplete dropdown + */ + hideAutocomplete() { + this.autocompleteContainer.classList.remove('show'); + this.autocompleteContainer.innerHTML = ''; + this.selectedIndex = -1; + } + + /** + * Update visual selection in autocomplete + */ + updateSelection() { + const items = this.autocompleteContainer.querySelectorAll('.autocomplete-item'); + items.forEach((item, index) => { + item.classList.toggle('selected', index === this.selectedIndex); + }); + } + + /** + * Select an address from autocomplete + */ + selectAddress(index) { + const result = this.autocompleteResults[index]; + if (result) { + this.addressInput.value = result.display_name || result.formatted_address || result.name; + this.addressInput.dataset.lat = result.lat || result.latitude; + this.addressInput.dataset.lon = result.lon || result.longitude; + this.hideAutocomplete(); + } + } + + /** + * Handle form submission + */ + async handleSubmit() { + const address = this.addressInput.value.trim(); + const description = this.descriptionInput.value.trim(); + const lat = parseFloat(this.addressInput.dataset.lat); + const lon = parseFloat(this.addressInput.dataset.lon); + + if (!address) { + this.showMessage('Please enter an address', 'error'); + return; + } + + if (!lat || !lon || isNaN(lat) || isNaN(lon)) { + this.showMessage('Please select a valid address from the suggestions', 'error'); + return; + } + + // Disable submit button + this.submitButton.disabled = true; + this.submitButton.innerHTML = ' Submitting...'; + + try { + const response = await fetch('/api/locations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + address, + latitude: lat, + longitude: lon, + description + }) + }); + + const data = await response.json(); + + if (response.ok) { + this.showMessage('Location reported successfully!', 'success'); + this.resetForm(); + this.refreshLocations(); + } else { + this.handleSubmitError(data); + } + } catch (error) { + console.error('Error submitting location:', error); + this.showMessage('Error submitting location. Please try again.', 'error'); + } finally { + this.submitButton.disabled = false; + this.submitButton.innerHTML = ' Report Ice'; + } + } + + /** + * Handle submission errors + */ + handleSubmitError(data) { + if (data.error === 'Submission rejected' && data.message) { + // Profanity rejection + this.showMessage(data.message, 'error', 10000); + } else { + this.showMessage(data.error || 'Error submitting location', 'error'); + } + } + + /** + * Reset form after successful submission + */ + resetForm() { + this.addressInput.value = ''; + this.addressInput.dataset.lat = ''; + this.addressInput.dataset.lon = ''; + this.descriptionInput.value = ''; + } + + /** + * Show a message to the user + */ + showMessage(text, type = 'info', duration = 5000) { + this.messageElement.textContent = text; + this.messageElement.className = `message ${type} show`; + + setTimeout(() => { + this.messageElement.classList.remove('show'); + }, duration); + } + + /** + * Clear all markers from the map + */ + clearMarkers() { + this.currentMarkers.forEach(marker => { + if (this.map && marker) { + this.map.removeLayer(marker); + } + }); + this.currentMarkers = []; + } + + /** + * Create a marker - to be implemented by subclasses + */ + createMarker(location) { + // To be implemented by subclasses + throw new Error('createMarker must be implemented by subclass'); + } + + /** + * Refresh locations from the server + */ + async refreshLocations() { + try { + const response = await fetch('/api/locations'); + const locations = await response.json(); + + this.displayLocations(locations); + this.updateLocationCount(locations.length); + + } catch (error) { + console.error('Error fetching locations:', error); + this.showMessage('Error loading locations', 'error'); + } + } + + /** + * Display locations on the map + */ + displayLocations(locations) { + this.clearMarkers(); + + locations.forEach(location => { + if (location.latitude && location.longitude) { + const marker = this.createMarker(location); + if (marker) { + this.currentMarkers.push(marker); + } + } + }); + + // Update locations list if container exists + if (this.locationsContainer) { + this.updateLocationsList(locations); + } + } + + /** + * Update the locations list (for table view) + */ + updateLocationsList(locations) { + if (!this.locationsContainer) return; + + if (locations.length === 0) { + this.locationsContainer.innerHTML = '

No ice reports in the last 48 hours

'; + return; + } + + this.locationsContainer.innerHTML = locations.map(location => ` +
+

${location.address}

+ ${location.description ? `

${location.description}

` : ''} +

${window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''}

+
+ `).join(''); + } + + /** + * Update location count display + */ + updateLocationCount(count) { + if (this.locationCountElement) { + const plural = count !== 1 ? 's' : ''; + this.locationCountElement.textContent = `${count} report${plural}`; + } + + if (this.lastUpdateElement) { + this.lastUpdateElement.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; + } + } + + /** + * Start automatic refresh of locations + */ + startAutoRefresh() { + // Initial load + this.refreshLocations(); + + // Refresh every 30 seconds + this.updateInterval = setInterval(() => { + this.refreshLocations(); + }, 30000); + } + + /** + * Stop automatic refresh + */ + stopAutoRefresh() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + /** + * Initialize the map - to be implemented by subclasses + */ + initializeMap() { + throw new Error('initializeMap must be implemented by subclass'); + } +} + +// Export for use in other files +window.MapBase = MapBase; \ No newline at end of file diff --git a/public/test-refactored.html b/public/test-refactored.html new file mode 100644 index 0000000..c43d116 --- /dev/null +++ b/public/test-refactored.html @@ -0,0 +1,266 @@ + + + + + + Test Refactored Maps + + + + + +
+

Refactored Map Components Test

+ +
+

Map Display

+
+ + + + +
+ +
+
+
+ + + +
+ Loading... | + Initializing... | + 0 reports +
+
+ +
+

Location Submission Form

+
+
+ +
+ +
+
+
+ +
+ + +
+ + +
+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/routes/admin.js b/routes/admin.js index f770535..5c2ca53 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); -module.exports = (db, profanityFilter, authenticateAdmin) => { +module.exports = (locationModel, profanityWordModel, profanityFilter, authenticateAdmin) => { // Admin login router.post('/login', (req, res) => { console.log('Admin login attempt'); @@ -18,36 +18,31 @@ module.exports = (db, profanityFilter, authenticateAdmin) => { }); // Get all locations for admin (including expired ones) - router.get('/locations', authenticateAdmin, (req, res) => { - db.all( - 'SELECT id, address, description, latitude, longitude, persistent, created_at FROM locations ORDER BY created_at DESC', - [], - (err, rows) => { - if (err) { - console.error('Error fetching all locations:', err); - res.status(500).json({ error: 'Internal server error' }); - return; - } - - // Process and clean data before sending - const locations = rows.map(row => ({ - id: row.id, - address: row.address, - description: row.description || '', - latitude: row.latitude, - longitude: row.longitude, - persistent: !!row.persistent, - created_at: row.created_at, - isActive: new Date(row.created_at) > new Date(Date.now() - 48 * 60 * 60 * 1000) - })); - - res.json(locations); - } - ); + router.get('/locations', authenticateAdmin, async (req, res) => { + try { + const rows = await locationModel.getAll(); + + // Process and clean data before sending + const locations = rows.map(row => ({ + id: row.id, + address: row.address, + description: row.description || '', + latitude: row.latitude, + longitude: row.longitude, + persistent: !!row.persistent, + created_at: row.created_at, + isActive: new Date(row.created_at) > new Date(Date.now() - 48 * 60 * 60 * 1000) + })); + + res.json(locations); + } catch (err) { + console.error('Error fetching all locations:', err); + res.status(500).json({ error: 'Internal server error' }); + } }); // Update a location (admin only) - router.put('/locations/:id', authenticateAdmin, (req, res) => { + router.put('/locations/:id', authenticateAdmin, async (req, res) => { const { id } = req.params; const { address, latitude, longitude, description } = req.body; @@ -56,28 +51,28 @@ module.exports = (db, profanityFilter, authenticateAdmin) => { return; } - db.run( - 'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?', - [address, latitude, longitude, description, id], - function(err) { - if (err) { - console.error('Error updating location:', err); - res.status(500).json({ error: 'Internal server error' }); - return; - } - - if (this.changes === 0) { - res.status(404).json({ error: 'Location not found' }); - return; - } - - res.json({ message: 'Location updated successfully' }); + try { + const result = await locationModel.update(id, { + address, + latitude, + longitude, + description + }); + + if (result.changes === 0) { + res.status(404).json({ error: 'Location not found' }); + return; } - ); + + res.json({ message: 'Location updated successfully' }); + } catch (err) { + console.error('Error updating location:', err); + res.status(500).json({ error: 'Internal server error' }); + } }); // Toggle persistent status of a location (admin only) - router.patch('/locations/:id/persistent', authenticateAdmin, (req, res) => { + router.patch('/locations/:id/persistent', authenticateAdmin, async (req, res) => { const { id } = req.params; const { persistent } = req.body; @@ -86,45 +81,39 @@ module.exports = (db, profanityFilter, authenticateAdmin) => { return; } - db.run( - 'UPDATE locations SET persistent = ? WHERE id = ?', - [persistent ? 1 : 0, id], - function(err) { - if (err) { - console.error('Error updating persistent status:', err); - res.status(500).json({ error: 'Internal server error' }); - return; - } - - if (this.changes === 0) { - res.status(404).json({ error: 'Location not found' }); - return; - } - - console.log(`Location ${id} persistent status set to ${persistent}`); - res.json({ message: 'Persistent status updated successfully', persistent }); - } - ); - }); - - // Delete a location (admin authentication required) - router.delete('/locations/:id', authenticateAdmin, (req, res) => { - const { id } = req.params; - - db.run('DELETE FROM locations WHERE id = ?', [id], function(err) { - if (err) { - console.error('Error deleting location:', err); - res.status(500).json({ error: 'Internal server error' }); + try { + const result = await locationModel.togglePersistent(id, persistent); + + if (result.changes === 0) { + res.status(404).json({ error: 'Location not found' }); return; } - if (this.changes === 0) { + console.log(`Location ${id} persistent status set to ${persistent}`); + res.json({ message: 'Persistent status updated successfully', persistent }); + } catch (err) { + console.error('Error updating persistent status:', err); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + // Delete a location (admin authentication required) + router.delete('/locations/:id', authenticateAdmin, async (req, res) => { + const { id } = req.params; + + try { + const result = await locationModel.delete(id); + + if (result.changes === 0) { res.status(404).json({ error: 'Location not found' }); return; } res.json({ message: 'Location deleted successfully' }); - }); + } catch (err) { + console.error('Error deleting location:', err); + res.status(500).json({ error: 'Internal server error' }); + } }); // Profanity Management Routes diff --git a/routes/locations.js b/routes/locations.js index 3af8bc1..ca3f2eb 100644 --- a/routes/locations.js +++ b/routes/locations.js @@ -1,29 +1,23 @@ const express = require('express'); const router = express.Router(); -module.exports = (db, profanityFilter) => { +module.exports = (locationModel, profanityFilter) => { // Get all active locations (within 48 hours OR persistent) - router.get('/', (req, res) => { + router.get('/', async (req, res) => { console.log('Fetching active locations'); - const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(); - db.all( - 'SELECT * FROM locations WHERE created_at > ? OR persistent = 1 ORDER BY created_at DESC', - [fortyEightHoursAgo], - (err, rows) => { - if (err) { - console.error('Error fetching locations:', err); - res.status(500).json({ error: 'Internal server error' }); - return; - } - console.log(`Fetched ${rows.length} active locations (including persistent)`); - res.json(rows); - } - ); + try { + const locations = await locationModel.getActive(); + console.log(`Fetched ${locations.length} active locations (including persistent)`); + res.json(locations); + } catch (err) { + console.error('Error fetching locations:', err); + res.status(500).json({ error: 'Internal server error' }); + } }); // Add a new location - router.post('/', (req, res) => { + router.post('/', async (req, res) => { const { address, latitude, longitude } = req.body; let { description } = req.body; console.log(`Attempt to add new location: ${address}`); @@ -61,48 +55,43 @@ module.exports = (db, profanityFilter) => { } } - db.run( - 'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)', - [address, latitude, longitude, description], - function(err) { - if (err) { - console.error('Error inserting location:', err); - res.status(500).json({ error: 'Internal server error' }); - return; - } - - console.log(`Location added successfully: ${address}`); - res.json({ - id: this.lastID, - address, - latitude, - longitude, - description, - created_at: new Date().toISOString() - }); - } - ); + try { + const newLocation = await locationModel.create({ + address, + latitude, + longitude, + description + }); + + console.log(`Location added successfully: ${address}`); + res.json({ + ...newLocation, + created_at: new Date().toISOString() + }); + } catch (err) { + console.error('Error inserting location:', err); + res.status(500).json({ error: 'Internal server error' }); + } }); // Legacy delete route (keeping for backwards compatibility) - router.delete('/:id', (req, res) => { + router.delete('/:id', async (req, res) => { const { id } = req.params; - db.run('DELETE FROM locations WHERE id = ?', [id], function(err) { - if (err) { - console.error('Error deleting location:', err); - res.status(500).json({ error: 'Internal server error' }); - return; - } + try { + const result = await locationModel.delete(id); - if (this.changes === 0) { + if (result.changes === 0) { res.status(404).json({ error: 'Location not found' }); return; } res.json({ message: 'Location deleted successfully' }); - }); + } catch (err) { + console.error('Error deleting location:', err); + res.status(500).json({ error: 'Internal server error' }); + } }); return router; -}; +}; \ No newline at end of file diff --git a/server.js b/server.js index f981917..c98db53 100644 --- a/server.js +++ b/server.js @@ -2,10 +2,10 @@ require('dotenv').config({ path: '.env.local' }); require('dotenv').config(); const express = require('express'); const cors = require('cors'); -const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const cron = require('node-cron'); -const ProfanityFilter = require('./profanity-filter'); +const DatabaseService = require('./services/DatabaseService'); +const ProfanityFilterService = require('./services/ProfanityFilterService'); // Import route modules const configRoutes = require('./routes/config'); @@ -20,12 +20,8 @@ app.use(cors()); app.use(express.json()); app.use(express.static('public')); -// Database setup -const db = new sqlite3.Database('icewatch.db'); - -console.log('Database connection established'); - -// Initialize profanity filter with its own database (async) +// Database and services setup +const databaseService = new DatabaseService(); let profanityFilter; // Create fallback filter function @@ -82,8 +78,10 @@ function createFallbackFilter() { // Initialize profanity filter asynchronously async function initializeProfanityFilter() { try { - profanityFilter = await ProfanityFilter.create(); - console.log('Profanity filter initialized successfully with separate database'); + const profanityWordModel = databaseService.getProfanityWordModel(); + profanityFilter = new ProfanityFilterService(profanityWordModel); + await profanityFilter.initialize(); + console.log('Profanity filter initialized successfully'); } catch (error) { console.error('WARNING: Failed to initialize profanity filter:', error); console.error('Creating fallback no-op profanity filter. ALL CONTENT WILL BE ALLOWED!'); @@ -94,45 +92,18 @@ async function initializeProfanityFilter() { } } -// Initialize database -db.serialize(() => { - db.run(`CREATE TABLE IF NOT EXISTS locations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - address TEXT NOT NULL, - latitude REAL, - longitude REAL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - description TEXT, - persistent INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, (err) => { - if (err) { - console.error('Error initializing database:', err); - } else { - console.log('Database initialized successfully'); - // Add persistent column to existing tables if it doesn't exist - db.run(`ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0`, (alterErr) => { - if (alterErr && !alterErr.message.includes('duplicate column name')) { - console.error('Error adding persistent column:', alterErr); - } else if (!alterErr) { - console.log('Added persistent column to existing table'); - } - }); - } - }); -}); +// Database initialization is now handled by DatabaseService // Clean up expired locations (older than 48 hours, but not persistent ones) -const cleanupExpiredLocations = () => { +const cleanupExpiredLocations = async () => { console.log('Running cleanup of expired locations'); - const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(); - db.run('DELETE FROM locations WHERE created_at < ? AND persistent = 0', [fortyEightHoursAgo], function(err) { - if (err) { - console.error('Error cleaning up expired locations:', err); - } else { - console.log(`Cleaned up ${this.changes} expired locations (persistent reports preserved)`); - } - }); + try { + const locationModel = databaseService.getLocationModel(); + const result = await locationModel.cleanupExpired(); + console.log(`Cleaned up ${result.changes} expired locations (persistent reports preserved)`); + } catch (err) { + console.error('Error cleaning up expired locations:', err); + } }; // Run cleanup every hour @@ -159,10 +130,13 @@ const authenticateAdmin = (req, res, next) => { // Setup routes after database and profanity filter are initialized function setupRoutes() { + const locationModel = databaseService.getLocationModel(); + const profanityWordModel = databaseService.getProfanityWordModel(); + // API Routes app.use('/api/config', configRoutes()); - app.use('/api/locations', locationRoutes(db, profanityFilter)); - app.use('/api/admin', adminRoutes(db, profanityFilter, authenticateAdmin)); + app.use('/api/locations', locationRoutes(locationModel, profanityFilter)); + app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin)); // Static page routes app.get('/', (req, res) => { @@ -184,7 +158,11 @@ function setupRoutes() { // Async server startup function async function startServer() { try { - // Initialize profanity filter first + // Initialize database service first + await databaseService.initialize(); + console.log('Database service initialized successfully'); + + // Initialize profanity filter await initializeProfanityFilter(); // Validate profanity filter is properly initialized @@ -238,13 +216,8 @@ process.on('SIGINT', () => { } } - // Close main database - db.close((err) => { - if (err) { - console.error('Error closing database:', err); - } else { - console.log('Main database connection closed.'); - } - process.exit(0); - }); -}); + // Close database service + databaseService.close(); + console.log('Database connections closed.'); + process.exit(0); +}); \ No newline at end of file diff --git a/services/DatabaseService.js b/services/DatabaseService.js new file mode 100644 index 0000000..3b9c100 --- /dev/null +++ b/services/DatabaseService.js @@ -0,0 +1,93 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const Location = require('../models/Location'); +const ProfanityWord = require('../models/ProfanityWord'); + +class DatabaseService { + constructor() { + this.mainDb = null; + this.profanityDb = null; + this.locationModel = null; + this.profanityWordModel = null; + } + + async initialize() { + await this.initializeMainDatabase(); + await this.initializeProfanityDatabase(); + } + + async initializeMainDatabase() { + return new Promise((resolve, reject) => { + const dbPath = path.join(__dirname, '..', 'icewatch.db'); + this.mainDb = new sqlite3.Database(dbPath, async (err) => { + if (err) { + console.error('Could not connect to main database', err); + return reject(err); + } + console.log('Connected to main SQLite database.'); + + this.locationModel = new Location(this.mainDb); + try { + await this.locationModel.initializeTable(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + } + + async initializeProfanityDatabase() { + return new Promise((resolve, reject) => { + const dbPath = path.join(__dirname, '..', 'profanity.db'); + this.profanityDb = new sqlite3.Database(dbPath, async (err) => { + if (err) { + console.error('Could not connect to profanity database', err); + return reject(err); + } + console.log('Connected to profanity SQLite database.'); + + this.profanityWordModel = new ProfanityWord(this.profanityDb); + try { + await this.profanityWordModel.initializeTable(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + } + + getLocationModel() { + if (!this.locationModel) { + throw new Error('Database not initialized. Call initialize() first.'); + } + return this.locationModel; + } + + getProfanityWordModel() { + if (!this.profanityWordModel) { + throw new Error('Database not initialized. Call initialize() first.'); + } + return this.profanityWordModel; + } + + getMainDb() { + return this.mainDb; + } + + getProfanityDb() { + return this.profanityDb; + } + + close() { + if (this.mainDb) { + this.mainDb.close(); + } + if (this.profanityDb) { + this.profanityDb.close(); + } + } +} + +module.exports = DatabaseService; \ No newline at end of file diff --git a/services/ProfanityFilterService.js b/services/ProfanityFilterService.js new file mode 100644 index 0000000..a7ca723 --- /dev/null +++ b/services/ProfanityFilterService.js @@ -0,0 +1,335 @@ +/** + * Refactored Profanity Filter Service that uses the ProfanityWord model + */ + +class ProfanityFilterService { + constructor(profanityWordModel) { + this.profanityWordModel = profanityWordModel; + this.isInitialized = false; + + // Base profanity words - comprehensive list + this.baseProfanityWords = [ + // Common profanity + 'damn', 'hell', 'crap', 'shit', 'fuck', 'ass', 'bitch', 'bastard', + 'piss', 'whore', 'slut', 'retard', 'fag', 'gay', 'homo', 'tranny', + 'dickhead', 'asshole', 'motherfucker', 'cocksucker', 'twat', 'cunt', + + // Racial slurs and hate speech + 'nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', + 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey', + 'kyke', 'jigaboo', 'coon', 'darkie', 'mammy', 'pickaninny', + + // Sexual content + 'penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum', + 'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate', 'orgasm', + 'blowjob', 'handjob', 'anal', 'penetration', 'erection', 'climax', + + // Violence and threats + 'kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape', + 'violence', 'assault', 'attack', 'threat', 'harm', 'hurt', 'pain', + 'stab', 'strangle', 'torture', 'execute', 'assassinate', 'slaughter', + + // Drugs and substances + 'weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', + 'stoned', 'drunk', 'alcohol', 'beer', 'liquor', 'vodka', 'whiskey', + 'ecstasy', 'lsd', 'crack', 'dope', 'pot', 'joint', 'bong', + + // Religious/cultural insults + 'jesus christ', 'goddamn', 'christ almighty', 'holy shit', 'god damn', + 'for christ sake', 'jesus fucking christ', 'holy fuck', + + // Body parts (inappropriate context) + 'testicles', 'balls', 'scrotum', 'clitoris', 'labia', 'anus', + 'rectum', 'butthole', 'nipples', 'breasts', + + // Misc inappropriate + 'wtf', 'omfg', 'stfu', 'gtfo', 'milf', 'dilf', 'thot', 'simp', + 'incel', 'chad', 'beta', 'alpha male', 'mansplain', 'karen' + ]; + + // Leetspeak and common substitutions + this.leetMap = { + '0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '6': 'g', '7': 't', + '8': 'b', '9': 'g', '@': 'a', '$': 's', '!': 'i', '+': 't', '*': 'a', + '%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/' + }; + + // Initialize custom words array + this.customWords = []; + + // Initialize patterns to null; will be built during async initialization + this.patterns = null; + } + + /** + * Initialize the filter by loading custom words + */ + async initialize() { + if (this.isInitialized) { + return; + } + + try { + await this.loadCustomWords(); + this.isInitialized = true; + console.log('ProfanityFilterService initialization completed successfully'); + } catch (error) { + console.error('Error during ProfanityFilterService initialization:', error); + throw error; + } + } + + /** + * Load custom words from database using the model + */ + async loadCustomWords() { + try { + const rows = await this.profanityWordModel.loadWords(); + + this.customWords = rows.map(row => ({ + word: row.word.toLowerCase(), + severity: row.severity, + category: row.category + })); + + console.log(`Loaded ${this.customWords.length} custom profanity words`); + this.patterns = this.buildPatterns(); // Rebuild patterns with custom words + } catch (err) { + console.error('Error loading custom profanity words:', err); + throw err; + } + } + + /** + * Build regex patterns for all profanity words + */ + buildPatterns() { + const allWords = [...this.baseProfanityWords, ...this.customWords.map(w => w.word)]; + + // Sort by length (longest first) to catch longer variations before shorter ones + allWords.sort((a, b) => b.length - a.length); + + // Create patterns with word boundaries and common variations + return allWords.map(word => { + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = escaped + .split('') + .map(char => { + const leetChars = Object.entries(this.leetMap) + .filter(([_, v]) => v === char.toLowerCase()) + .map(([k, _]) => k); + + if (leetChars.length > 0) { + const allChars = [char, ...leetChars].map(c => + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); + return `[${allChars.join('')}]`; + } + return char; + }) + .join('[\\s\\-\\_\\*\\.]*'); + + return { + word: word, + pattern: new RegExp(`\\b${pattern}\\b`, 'gi'), + severity: this.getSeverity(word), + category: this.getCategory(word) + }; + }); + } + + /** + * Get severity level for a word + */ + getSeverity(word) { + // Check custom words first + const customWord = this.customWords.find(w => w.word === word.toLowerCase()); + if (customWord) { + return customWord.severity; + } + + // Categorize severity based on type + const highSeverity = ['nigger', 'nigga', 'cunt', 'fag', 'retard', 'kike', 'spic', 'gook', 'chink']; + const lowSeverity = ['damn', 'hell', 'crap', 'wtf', 'omfg']; + + if (highSeverity.includes(word.toLowerCase())) return 'high'; + if (lowSeverity.includes(word.toLowerCase())) return 'low'; + return 'medium'; + } + + /** + * Get category for a word + */ + getCategory(word) { + // Check custom words first + const customWord = this.customWords.find(w => w.word === word.toLowerCase()); + if (customWord) { + return customWord.category; + } + + // Categorize based on type + const categories = { + racial: ['nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey'], + sexual: ['penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum', 'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate'], + violence: ['kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape', 'violence', 'assault', 'attack'], + substance: ['weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'stoned', 'drunk', 'alcohol'], + general: ['shit', 'fuck', 'ass', 'bitch', 'bastard', 'damn', 'hell', 'crap'] + }; + + for (const [category, words] of Object.entries(categories)) { + if (words.includes(word.toLowerCase())) { + return category; + } + } + + return 'general'; + } + + /** + * Normalize text for checking + */ + normalizeText(text) { + if (!text) return ''; + + // Convert to lowercase and handle basic substitutions + let normalized = text.toLowerCase(); + + // Replace multiple spaces/special chars with single space + normalized = normalized.replace(/[\s\-\_\*\.]+/g, ' '); + + // Apply leet speak conversions + normalized = normalized.split('').map(char => + this.leetMap[char] || char + ).join(''); + + return normalized; + } + + /** + * Check if text contains profanity + */ + containsProfanity(text) { + if (!text || !this.patterns) return false; + + const normalized = this.normalizeText(text); + return this.patterns.some(({ pattern }) => pattern.test(normalized)); + } + + /** + * Analyze text for profanity with detailed results + */ + analyzeProfanity(text) { + if (!text || !this.patterns) { + return { + hasProfanity: false, + matches: [], + severity: 'none', + count: 0, + filtered: text || '' + }; + } + + const normalized = this.normalizeText(text); + const matches = []; + let filteredText = text; + + this.patterns.forEach(({ word, pattern, severity, category }) => { + const regex = new RegExp(pattern.source, 'gi'); + let match; + + while ((match = regex.exec(normalized)) !== null) { + matches.push({ + word: word, + found: match[0], + index: match.index, + severity: severity, + category: category + }); + + // Replace in filtered text + const replacement = '*'.repeat(match[0].length); + filteredText = filteredText.substring(0, match.index) + + replacement + + filteredText.substring(match.index + match[0].length); + } + }); + + // Determine overall severity + let overallSeverity = 'none'; + if (matches.length > 0) { + if (matches.some(m => m.severity === 'high')) { + overallSeverity = 'high'; + } else if (matches.some(m => m.severity === 'medium')) { + overallSeverity = 'medium'; + } else { + overallSeverity = 'low'; + } + } + + return { + hasProfanity: matches.length > 0, + matches: matches, + severity: overallSeverity, + count: matches.length, + filtered: filteredText + }; + } + + /** + * Filter profanity from text + */ + filterProfanity(text, replacementChar = '*') { + const analysis = this.analyzeProfanity(text); + return analysis.filtered; + } + + /** + * Add a custom word using the model + */ + async addCustomWord(word, severity = 'medium', category = 'custom', createdBy = 'admin') { + try { + const result = await this.profanityWordModel.create(word, severity, category, createdBy); + await this.loadCustomWords(); // Reload to update patterns + return result; + } catch (err) { + if (err.message.includes('UNIQUE constraint failed')) { + throw new Error('Word already exists in the filter'); + } + throw err; + } + } + + /** + * Remove a custom word using the model + */ + async removeCustomWord(wordId) { + const result = await this.profanityWordModel.delete(wordId); + if (result.changes === 0) { + throw new Error('Word not found'); + } + await this.loadCustomWords(); // Reload to update patterns + return { deleted: true, changes: result.changes }; + } + + /** + * Get all custom words using the model + */ + async getCustomWords() { + return await this.profanityWordModel.getAll(); + } + + /** + * Update a custom word using the model + */ + async updateCustomWord(wordId, updates) { + const { word, severity, category } = updates; + const result = await this.profanityWordModel.update(wordId, word, severity, category); + if (result.changes === 0) { + throw new Error('Word not found'); + } + await this.loadCustomWords(); // Reload to update patterns + return { updated: true, changes: result.changes }; + } +} + +module.exports = ProfanityFilterService; \ No newline at end of file